From a0a9c233bbd03651298448b3381924c6644f74f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20H=C3=BChn?= Date: Fri, 17 Jul 2026 20:06:50 +0200 Subject: [PATCH 01/52] feat(analysis): add DomainLanguageParser emitting cc.json 2.0 domain lens Port the DomainLanguageCharta analyzer into ccsh as a first-class parser (`ccsh domainlanguageparser`) that extracts domain vocabulary (word frequencies, optional n-grams, TF-IDF) from source code and writes it into the reserved cc.json 2.0 `domain` lens, keyed by node id over the standard files tree. - New writer (DomainProjectGenerator) builds a Project + opaque `domain` lens; keys computed with NodeId.fromSegments so they resolve against the ids the 2.0 writer emits (File leaves, Folder dirs, empty-segment root). tfidf omitted when null; deterministic, byte-stable output. - picocli command (CommonAnalyserParameters + AnalyserInterface) with a Dialog for interactive mode; registered in Ccsh.kt, ccsh build, settings, and the ccsh analyser tests. Not in AttributeGeneratorRegistry (opaque lens, not numeric metrics). - Reuses the ported analysis engine verbatim; retires DLC's {tree, words} JSON output and kotlinx-cli entrypoint. - Dependency dedupe: drop jgit (-> AnalyserInterface GitignoreHandler), mordant (-> model ProgressTracker), kotlinx-cli and the kotlin serialization plugin; keep kasechange and kotlinx-serialization-json. - Docs: analysis README parser table, module README, CHANGELOG, gh-pages parser page + sidebar. Verified equivalent to the original DLC tool: same word/frequency/TF-IDF data on identical inputs; domain lens survives a mergefilter round-trip. Co-Authored-By: Claude Fable 5 --- analysis/CHANGELOG.md | 1 + analysis/README.md | 1 + .../parsers/DomainLanguageParser/README.md | 68 ++ .../DomainLanguageParser/build.gradle.kts | 28 + .../parsers/domainlanguage/Dialog.kt | 128 ++++ .../domainlanguage/DomainLanguageParser.kt | 181 ++++++ .../parsers/domainlanguage/PathUtils.kt | 25 + .../parsers/domainlanguage/SourceAnalyzer.kt | 135 ++++ .../domainlanguage/SourceAnalyzerFactory.kt | 40 ++ .../parsers/domainlanguage/WordAnalyzer.kt | 7 + .../cli/AnalysisConfiguration.kt | 31 + .../cli/ConfigurationBuilder.kt | 80 +++ .../domainlanguage/cli/ParsedArguments.kt | 19 + .../parsers/domainlanguage/cli/SortBy.kt | 6 + .../domainlanguage/cli/StopWordLevel.kt | 7 + .../domainlanguage/input/FileFilter.kt | 9 + .../domainlanguage/input/FileScanner.kt | 62 ++ .../domainlanguage/input/TestFileDetector.kt | 61 ++ .../output/DirectoryWordAggregator.kt | 51 ++ .../output/DomainAnalysisResult.kt | 12 + .../output/DomainProjectGenerator.kt | 95 +++ .../domainlanguage/output/WordFrequency.kt | 11 + .../processing/CoroutineFileProcessor.kt | 54 ++ .../processing/ExtractionWeights.kt | 9 + .../domainlanguage/processing/FileAnalyzer.kt | 40 ++ .../processing/FileProcessor.kt | 25 + .../domainlanguage/processing/FileResult.kt | 13 + .../processing/FrameworkDetector.kt | 135 ++++ .../domainlanguage/processing/Language.kt | 107 +++ .../processing/PathScopedKeywordProvider.kt | 53 ++ .../processing/StopWordFilter.kt | 56 ++ .../processing/analysis/TfIdfCalculator.kt | 54 ++ .../processing/keywords/LanguageKeywords.kt | 5 + .../keywords/ResourceKeywordLoader.kt | 16 + .../processing/keywords/ResourceKeywords.kt | 17 + .../processing/pipeline/ExtractedText.kt | 15 + .../processing/pipeline/SourceCodePipeline.kt | 84 +++ .../processing/pipeline/WeightedText.kt | 10 + .../pipeline/stages/AggregateStage.kt | 20 + .../pipeline/stages/ExtractStage.kt | 34 + .../processing/pipeline/stages/FilterStage.kt | 27 + .../processing/pipeline/stages/NgramsStage.kt | 125 ++++ .../processing/pipeline/stages/SplitStage.kt | 60 ++ .../processing/pipeline/stages/WeightStage.kt | 26 + .../processing/stopwords/DlcIgnoreParser.kt | 24 + .../progress/ProgressReporter.kt | 27 + .../progress/ProgressReporterFactory.kt | 5 + .../progress/ProgressTrackerReporter.kt | 43 ++ .../progress/SilentProgressReporter.kt | 14 + .../main/resources/keywords/abl-keywords.txt | 83 +++ .../resources/keywords/angular-keywords.txt | 93 +++ .../resources/keywords/aspnet-keywords.txt | 170 +++++ .../main/resources/keywords/bash-keywords.txt | 96 +++ .../main/resources/keywords/c-keywords.txt | 70 ++ .../main/resources/keywords/cpp-keywords.txt | 209 ++++++ .../resources/keywords/csharp-keywords.txt | 242 +++++++ .../keywords/entityframework-keywords.txt | 208 ++++++ .../main/resources/keywords/go-keywords.txt | 75 +++ .../main/resources/keywords/java-keywords.txt | 124 ++++ .../keywords/javascript-keywords.txt | 53 ++ .../resources/keywords/kotlin-keywords.txt | 87 +++ .../main/resources/keywords/objc-keywords.txt | 111 ++++ .../main/resources/keywords/php-keywords.txt | 105 +++ .../resources/keywords/python-keywords.txt | 144 ++++ .../resources/keywords/react-keywords.txt | 107 +++ .../main/resources/keywords/ruby-keywords.txt | 84 +++ .../main/resources/keywords/rust-keywords.txt | 116 ++++ .../resources/keywords/swift-keywords.txt | 94 +++ .../keywords/technical-aggressive.txt | 132 ++++ .../resources/keywords/technical-minimal.txt | 23 + .../resources/keywords/technical-moderate.txt | 59 ++ .../keywords/typescript-keywords.txt | 89 +++ .../main/resources/keywords/vue-keywords.txt | 225 +++++++ .../resources/stopwords/english-stopwords.txt | 99 +++ .../SourceAnalyzerFactoryTest.kt | 90 +++ .../domainlanguage/SourceAnalyzerTest.kt | 615 ++++++++++++++++++ .../domainlanguage/WordAnalyzerTest.kt | 28 + .../cli/AnalysisConfigurationTest.kt | 174 +++++ .../cli/ConfigurationBuilderTest.kt | 475 ++++++++++++++ .../domainlanguage/cli/ParsedArgumentsTest.kt | 160 +++++ .../domainlanguage/input/FileFilterTest.kt | 138 ++++ .../domainlanguage/input/FileScannerTest.kt | 574 ++++++++++++++++ .../input/TestFileDetectorTest.kt | 346 ++++++++++ .../output/DirectoryWordAggregatorTest.kt | 147 +++++ .../output/DomainProjectGeneratorTest.kt | 174 +++++ .../processing/CoroutineFileProcessorTest.kt | 171 +++++ .../processing/ExtractionWeightsTest.kt | 126 ++++ .../processing/FileAnalyzerTest.kt | 283 ++++++++ .../processing/FrameworkDetectorTest.kt | 446 +++++++++++++ .../domainlanguage/processing/LanguageTest.kt | 448 +++++++++++++ ...ScopedFrameworkFilteringIntegrationTest.kt | 180 +++++ .../PathScopedKeywordProviderTest.kt | 157 +++++ .../processing/StopWordFilterTest.kt | 573 ++++++++++++++++ .../analysis/TfIdfCalculatorTest.kt | 142 ++++ .../keywords/ResourceKeywordLoaderTest.kt | 64 ++ .../keywords/ResourceKeywordsTest.kt | 111 ++++ .../pipeline/SourceCodePipelineTest.kt | 502 ++++++++++++++ .../pipeline/stages/ExtractStageTest.kt | 395 +++++++++++ .../pipeline/stages/NgramsStageTest.kt | 487 ++++++++++++++ .../pipeline/stages/SplitStageTest.kt | 284 ++++++++ .../stopwords/DlcIgnoreParserTest.kt | 268 ++++++++ .../progress/ProgressReporterFactoryTest.kt | 25 + .../progress/SilentProgressReporterTest.kt | 34 + analysis/ccsh/build.gradle.kts | 3 +- .../de/maibornwolff/codecharta/ccsh/Ccsh.kt | 4 +- .../ccsh/analyser/AnalyserServiceTest.kt | 2 + .../PicocliAnalyserRepositoryTest.kt | 4 +- analysis/settings.gradle.kts | 3 +- gh-pages/astro.config.mjs | 1 + .../docs/docs/parser/domain-language.md | 62 ++ plans/2026-07-14-domainlanguage-parser.md | 104 +++ 111 files changed, 12850 insertions(+), 4 deletions(-) create mode 100644 analysis/analysers/parsers/DomainLanguageParser/README.md create mode 100644 analysis/analysers/parsers/DomainLanguageParser/build.gradle.kts create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/Dialog.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/DomainLanguageParser.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/PathUtils.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzer.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerFactory.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/WordAnalyzer.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/AnalysisConfiguration.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ConfigurationBuilder.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ParsedArguments.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/SortBy.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/StopWordLevel.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileFilter.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileScanner.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/TestFileDetector.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DirectoryWordAggregator.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainAnalysisResult.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainProjectGenerator.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/WordFrequency.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/CoroutineFileProcessor.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/ExtractionWeights.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileAnalyzer.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileProcessor.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileResult.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FrameworkDetector.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/Language.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedKeywordProvider.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/StopWordFilter.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/analysis/TfIdfCalculator.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/LanguageKeywords.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordLoader.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywords.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/ExtractedText.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/SourceCodePipeline.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/WeightedText.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/AggregateStage.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/ExtractStage.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/FilterStage.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/NgramsStage.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/SplitStage.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/WeightStage.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/stopwords/DlcIgnoreParser.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporter.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporterFactory.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressTrackerReporter.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/SilentProgressReporter.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/abl-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/angular-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/aspnet-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/bash-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/c-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/cpp-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/csharp-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/entityframework-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/go-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/java-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/javascript-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/kotlin-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/objc-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/php-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/python-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/react-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/ruby-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/rust-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/swift-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-aggressive.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-minimal.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-moderate.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/typescript-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/vue-keywords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/main/resources/stopwords/english-stopwords.txt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerFactoryTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/WordAnalyzerTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/AnalysisConfigurationTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ConfigurationBuilderTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ParsedArgumentsTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileFilterTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileScannerTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/TestFileDetectorTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DirectoryWordAggregatorTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainProjectGeneratorTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/CoroutineFileProcessorTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/ExtractionWeightsTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileAnalyzerTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FrameworkDetectorTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/LanguageTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedFrameworkFilteringIntegrationTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedKeywordProviderTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/StopWordFilterTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/analysis/TfIdfCalculatorTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordLoaderTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordsTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/SourceCodePipelineTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/ExtractStageTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/NgramsStageTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/SplitStageTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/stopwords/DlcIgnoreParserTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporterFactoryTest.kt create mode 100644 analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/SilentProgressReporterTest.kt create mode 100644 gh-pages/src/content/docs/docs/parser/domain-language.md create mode 100644 plans/2026-07-14-domainlanguage-parser.md diff --git a/analysis/CHANGELOG.md b/analysis/CHANGELOG.md index 761aba5ea1..c11a93b990 100644 --- a/analysis/CHANGELOG.md +++ b/analysis/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/) ### Added 🚀 +- Add DomainLanguageParser to extract domain vocabulary (word frequencies) from source code into a cc.json 2.0 `domain` lens - Add Rust support to UnifiedParser (.rs) - Add JSX support to UnifiedParser - Add TSX support to UnifiedParser diff --git a/analysis/README.md b/analysis/README.md index 4ee645efd7..15013934d6 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -17,6 +17,7 @@ Components that generate metrics from a given source, e.g. source code or log fi | Git log | [GitLogParser](analysers/parsers/GitLogParser/README.md) | | Source Code / Text | [RawTextParser](analysers/parsers/RawTextParser/README.md) | | Source Code | [UnifiedParser](analysers/parsers/UnifiedParser/README.md) | +| Domain Language | [DomainLanguageParser](analysers/parsers/DomainLanguageParser/README.md) | | SVN log | [SVNLogParser](analysers/parsers/SVNLogParser/README.md) | ### Importer diff --git a/analysis/analysers/parsers/DomainLanguageParser/README.md b/analysis/analysers/parsers/DomainLanguageParser/README.md new file mode 100644 index 0000000000..7259a06cec --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/README.md @@ -0,0 +1,68 @@ +# Domain Language Parser + +**Category**: Parser (takes in source code and outputs cc.json) + +This parser extracts the *domain vocabulary* of a codebase: it tokenizes identifiers, comments and +string literals across the supported languages, filters out programming-language keywords and +technical stop words, and counts how often each (optionally n-gram) word occurs. The resulting +word-frequency data is written into the reserved cc.json 2.0 **`domain` lens**, keyed by node id, for +every file and aggregated for every folder (and the project root). + +The `domain` lens payload is a bare map `{ "": [{ "text", "frequency", "tfidf"? }, ...] }`. +`tfidf` is only present when TF-IDF scoring is enabled and defined. + +## Supported Languages + +Kotlin, Java, TypeScript, JavaScript, Python, C#, Go, C, C++, PHP, Ruby, Swift, Objective-C and shell +(the full set of extensions handled by the underlying tree-sitter analysis). + +## Usage and Parameters + +| Parameter | Description | +|-------------------------------------------|-------------------------------------------------------------------------------------------------| +| `FILE or FOLDER` | file/project to parse | +| `-o, --output-file=` | output file (or empty for stdout) | +| `-nc, --not-compressed` | save uncompressed output file | +| `-fe, --file-extensions=` | comma-separated list of extensions to analyse only those files (default: all supported) | +| `--bypass-gitignore` | disable automatic .gitignore-based file exclusion | +| `--commit=` | analyze the codebase at a specific git commit/tag/branch (creates a temporary worktree) | +| `--verbose` | verbose mode (also shows an analysis progress bar) | +| `--exclude-tests` | exclude test files from the analysis (test files are included by default) | +| `--ngrams=` | generate n-grams up to size N (1=words, 2=bigrams, 3=trigrams; default 1) | +| `--no-ssr` | disable Statistical Substring Reduction for n-grams (enabled by default when `--ngrams` > 1) | +| `--limit=` | limit each node to its top X words (all words if not set) | +| `--sort-by=` | sort words by frequency (default) or TF-IDF score | +| `--stop-word-level=` | technical stop word filtering level (default MODERATE) | +| `--exclude-technical-stopwords` | disable filtering of common technical words (e.g. `test`, `util`, `handler`) | +| `--identifier-weight=` | weight for identifier words (class/function/variable names; default 3) | +| `--comment-weight=` | weight for words in comments (default 2) | +| `--string-weight=` | weight for words in string literals (default 1) | +| `--no-tfidf` | disable TF-IDF scoring (enabled by default) | +| `-h, --help` | displays this help and exits | + +## Examples + +Analyze a project folder and write a compressed cc.json: + +``` +ccsh domainlanguageparser foo/bar/project -o out.cc.json +``` + +Keep only the top 25 words per node, ranked by TF-IDF: + +``` +ccsh domainlanguageparser foo/bar/project --limit=25 --sort-by=TFIDF -o out.cc.json +``` + +Include bigrams and trigrams and exclude test files: + +``` +ccsh domainlanguageparser foo/bar/project --ngrams=3 --exclude-tests -o out.cc.json +``` + +If a project is piped into the DomainLanguageParser, the results and the piped project are merged. + +> The infrastructure options `-e/--exclude`, `-bf/--base-file`, `--local-changes` and +> `-ibf/--include-build-folders` are inherited from the common analyser parameters but do not affect +> domain-vocabulary analysis and are ignored. + diff --git a/analysis/analysers/parsers/DomainLanguageParser/build.gradle.kts b/analysis/analysers/parsers/DomainLanguageParser/build.gradle.kts new file mode 100644 index 0000000000..877fc55d23 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/build.gradle.kts @@ -0,0 +1,28 @@ +dependencies { + implementation(project(":model")) + implementation(project(":dialogProvider")) + implementation(project(":analysers:AnalyserInterface")) + implementation(project(":analysers:filters:MergeFilter")) + + implementation(libs.picocli) + implementation(libs.gson) + implementation(libs.kotter) + implementation(libs.kotter.test) + + // TreesitterLibrary provides all TreeSitter dependencies (aligned to UnifiedParser's v0.12.0 + // to avoid a duplicate-class clash on the shared ccsh runtime classpath). + implementation("com.github.MaibornWolff:TreeSitterExcavationSite:v0.12.0") + + // Identifier casing splitter — no codecharta equivalent, kept long-term. + implementation("net.pearx.kasechange:kasechange:1.4.1") + + // Runtime JSON parsing of package.json for framework detection (no @Serializable classes, so the + // kotlin-serialization compiler plugin is not needed). + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/Dialog.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/Dialog.kt new file mode 100644 index 0000000000..3a4e5ce379 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/Dialog.kt @@ -0,0 +1,128 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage + +import com.varabyte.kotter.runtime.RunScope +import com.varabyte.kotter.runtime.Session +import de.maibornwolff.codecharta.analysers.analyserinterface.AnalyserDialogInterface +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.SortBy +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.StopWordLevel +import de.maibornwolff.codecharta.dialogProvider.InputType +import de.maibornwolff.codecharta.dialogProvider.promptConfirm +import de.maibornwolff.codecharta.dialogProvider.promptDefaultDirectoryAssistedInput +import de.maibornwolff.codecharta.dialogProvider.promptInput +import de.maibornwolff.codecharta.dialogProvider.promptInputNumber +import de.maibornwolff.codecharta.dialogProvider.promptList + +class Dialog { + companion object : AnalyserDialogInterface { + override fun collectAnalyserArgs(session: Session): List { + val inputFileName = inputFileQuestion(session) + + val outputFileName = outputFileQuestion(session) + val isCompressed = outputFileName.isEmpty() || compressedQuestion(session) + val verbose = verboseQuestion(session) + + val useGitignore = useGitignoreQuestion(session) + val excludeTests = excludeTestsQuestion(session) + val ngrams = ngramsQuestion(session) + val noSsr = ngrams > 1 && noSsrQuestion(session) + val limit = limitQuestion(session) + val sortBy = sortByQuestion(session) + val stopWordLevel = stopWordLevelQuestion(session) + val excludeTechnicalStopwords = excludeTechnicalStopwordsQuestion(session) + val noTfidf = noTfidfQuestion(session) + + return listOfNotNull( + inputFileName, + "--output-file=$outputFileName", + if (isCompressed) null else "--not-compressed", + "--verbose=${!verbose}", + if (useGitignore) null else "--bypass-gitignore", + if (excludeTests) "--exclude-tests" else null, + "--ngrams=$ngrams", + if (noSsr) "--no-ssr" else null, + if (limit.isNotEmpty()) "--limit=$limit" else null, + "--sort-by=$sortBy", + "--stop-word-level=$stopWordLevel", + if (excludeTechnicalStopwords) "--exclude-technical-stopwords" else null, + if (noTfidf) "--no-tfidf" else null + ) + } + + private fun inputFileQuestion(session: Session): String = session.promptDefaultDirectoryAssistedInput( + inputType = InputType.FOLDER_AND_FILE, + fileExtensionList = listOf(), + onInputReady = testCallback() + ) + + private fun outputFileQuestion(session: Session): String = session.promptInput( + message = "What is the name of the output file?", + allowEmptyInput = true, + onInputReady = testCallback() + ) + + private fun compressedQuestion(session: Session): Boolean = session.promptConfirm( + message = "Do you want to compress the output file?", + onInputReady = testCallback() + ) + + private fun verboseQuestion(session: Session): Boolean = session.promptConfirm( + message = "Do you want to suppress command line output?", + onInputReady = testCallback() + ) + + private fun useGitignoreQuestion(session: Session): Boolean = session.promptConfirm( + message = "Exclude files specified in .gitignore files?", + onInputReady = testCallback() + ) + + private fun excludeTestsQuestion(session: Session): Boolean = session.promptConfirm( + message = "Do you want to exclude test files from the analysis?", + onInputReady = testCallback() + ) + + private fun ngramsQuestion(session: Session): Int { + val ngrams = session.promptInputNumber( + message = "Up to which n-gram size should words be combined (1=words, 2=bigrams, 3=trigrams)?", + hint = "1", + allowEmptyInput = true, + onInputReady = testCallback() + ) + return ngrams.toIntOrNull()?.takeIf { it >= 1 } ?: 1 + } + + private fun noSsrQuestion(session: Session): Boolean = session.promptConfirm( + message = "Do you want to disable Statistical Substring Reduction for n-grams?", + onInputReady = testCallback() + ) + + private fun limitQuestion(session: Session): String = session.promptInputNumber( + message = "How many top words should each node keep (leave empty to keep all)?", + allowEmptyInput = true, + onInputReady = testCallback() + ) + + private fun sortByQuestion(session: Session): String = session.promptList( + message = "How do you want to sort the words?", + choices = SortBy.entries.map { it.name }, + onInputReady = testCallback() + ) + + private fun stopWordLevelQuestion(session: Session): String = session.promptList( + message = "Which technical stop word filtering level do you want to use?", + choices = StopWordLevel.entries.map { it.name }, + onInputReady = testCallback() + ) + + private fun excludeTechnicalStopwordsQuestion(session: Session): Boolean = session.promptConfirm( + message = "Do you want to disable technical stop word filtering (e.g. 'test', 'util', 'handler')?", + onInputReady = testCallback() + ) + + private fun noTfidfQuestion(session: Session): Boolean = session.promptConfirm( + message = "Do you want to disable TF-IDF scoring?", + onInputReady = testCallback() + ) + + internal fun testCallback(): suspend RunScope.() -> Unit = {} + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/DomainLanguageParser.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/DomainLanguageParser.kt new file mode 100644 index 0000000000..34abda39a1 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/DomainLanguageParser.kt @@ -0,0 +1,181 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage + +import de.maibornwolff.codecharta.analysers.analyserinterface.AnalyserDialogInterface +import de.maibornwolff.codecharta.analysers.analyserinterface.AnalyserInterface +import de.maibornwolff.codecharta.analysers.analyserinterface.CommonAnalyserParameters +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.AnalysisConfiguration +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.ConfigurationBuilder +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.ParsedArguments +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.SortBy +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.StopWordLevel +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.DomainProjectGenerator +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.Language +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress.ProgressReporterFactory +import de.maibornwolff.codecharta.model.Project +import de.maibornwolff.codecharta.serialization.ProjectDeserializer +import de.maibornwolff.codecharta.serialization.ProjectSerializer +import de.maibornwolff.codecharta.util.CodeChartaConstants +import de.maibornwolff.codecharta.util.InputHelper +import de.maibornwolff.codecharta.util.Logger +import picocli.CommandLine +import java.io.File +import java.io.InputStream +import java.io.PrintStream + +@CommandLine.Command( + name = DomainLanguageParser.NAME, + description = [DomainLanguageParser.DESCRIPTION], + footer = [CodeChartaConstants.GENERIC_FOOTER] +) +class DomainLanguageParser(private val input: InputStream = System.`in`, private val output: PrintStream = System.out) : + CommonAnalyserParameters(), + AnalyserInterface { + @CommandLine.Option(names = ["--limit"], description = ["limit each node to its top X words (all words if not set)"]) + private var limit: Int? = null + + @CommandLine.Option(names = ["--ngrams"], description = ["generate n-grams up to size N (1=words, 2=bigrams, 3=trigrams)"]) + private var ngrams: Int = DEFAULT_NGRAMS + + @CommandLine.Option( + names = ["--stop-word-level"], + description = ["technical stop word filtering level: \${COMPLETION-CANDIDATES} (default: MODERATE)"] + ) + private var stopWordLevel: StopWordLevel = StopWordLevel.MODERATE + + @CommandLine.Option(names = ["--identifier-weight"], description = ["weight for identifier words (class/function/variable names)"]) + private var identifierWeight: Int = DEFAULT_IDENTIFIER_WEIGHT + + @CommandLine.Option(names = ["--comment-weight"], description = ["weight for words in comments"]) + private var commentWeight: Int = DEFAULT_COMMENT_WEIGHT + + @CommandLine.Option(names = ["--string-weight"], description = ["weight for words in string literals"]) + private var stringWeight: Int = DEFAULT_STRING_WEIGHT + + @CommandLine.Option( + names = ["--exclude-tests"], + description = ["exclude test files from analysis (test files are included by default)"] + ) + private var excludeTests: Boolean = false + + @CommandLine.Option( + names = ["--exclude-technical-stopwords"], + description = ["disable filtering of common technical words (e.g. 'test', 'util', 'handler')"] + ) + private var excludeTechnicalStopwords: Boolean = false + + @CommandLine.Option(names = ["--no-tfidf"], description = ["disable TF-IDF scoring (enabled by default)"]) + private var noTfidf: Boolean = false + + @CommandLine.Option(names = ["--sort-by"], description = ["sort words by: \${COMPLETION-CANDIDATES} (default: FREQUENCY)"]) + private var sortBy: SortBy = SortBy.FREQUENCY + + @CommandLine.Option( + names = ["--no-ssr"], + description = ["disable Statistical Substring Reduction for n-grams (enabled by default when ngrams > 1)"] + ) + private var noSsr: Boolean = false + + override val name = NAME + override val description = DESCRIPTION + + companion object { + const val NAME = "domainlanguageparser" + const val DESCRIPTION = "generates cc.json with a domain-language word-frequency lens from source code" + + private const val DEFAULT_IDENTIFIER_WEIGHT = 3 + private const val DEFAULT_COMMENT_WEIGHT = 2 + private const val DEFAULT_STRING_WEIGHT = 1 + private const val DEFAULT_NGRAMS = 1 + + private val SUPPORTED_EXTENSIONS = Language.allExtensions().toSet() + } + + override fun call(): Unit? { + logExecutionStartedSyncSignal() + + val inputFileIndex = extractNonPipedInputIndex(inputFiles) + val inputFile = inputFiles[inputFileIndex] + require(InputHelper.isInputValidAndNotNull(arrayOf(inputFile), canInputContainFolders = true)) { + "Input invalid file for DomainLanguageParser, stopping execution..." + } + validateOptions() + + val context = resolveEffectiveInput(inputFile) + try { + val directoryPath = context.inputDir.path + val config = buildConfiguration(directoryPath) + + val analysisResult = + ProgressReporterFactory.create(quiet = !verbose).use { progressReporter -> + SourceAnalyzerFactory.create(config, progressReporter).analyze(directoryPath) + } + + val project = DomainProjectGenerator().generate(analysisResult, resolvePipedProject()) + + ProjectSerializer.serializeToFileOrStream(project, context.resolveOutputFile(outputFile), output, compress) + } finally { + context.worktreeManager?.cleanup() + } + + return null + } + + private fun resolvePipedProject(): Project? { + if (!shouldProcessPipedInput(inputFiles)) return null + val pipedProject = ProjectDeserializer.deserializeProject(input) + if (pipedProject == null) { + Logger.warn { "Skipping piped project..." } + } + return pipedProject + } + + private fun validateOptions() { + require(identifierWeight > 0) { "--identifier-weight must be positive, got $identifierWeight" } + require(commentWeight > 0) { "--comment-weight must be positive, got $commentWeight" } + require(stringWeight > 0) { "--string-weight must be positive, got $stringWeight" } + require((limit ?: 0) >= 0) { "--limit must not be negative, got $limit" } + } + + // Honors the inherited -fe/--file-extensions option: when given, only those extensions are analysed + // instead of the full supported set. The other purely infrastructural inherited options + // (-e/--exclude, -bf/--base-file, --local-changes, -ibf/--include-build-folders) do not apply to + // domain-vocabulary analysis and are intentionally not consumed. + private fun buildConfiguration(directoryPath: String): AnalysisConfiguration { + val config = ConfigurationBuilder().build(buildParsedArguments(directoryPath)) + return if (fileExtensionsToAnalyse.isEmpty()) config else config.copy(allowedExtensions = fileExtensionsToAnalyse) + } + + private fun buildParsedArguments(directoryPath: String): ParsedArguments = ParsedArguments( + directory = directoryPath, + limit = limit, + bypassGitignore = bypassGitignore, + excludeTests = excludeTests, + output = null, + identifierWeight = identifierWeight, + commentWeight = commentWeight, + stringWeight = stringWeight, + excludeTechnicalStopWords = excludeTechnicalStopwords, + stopWordLevel = stopWordLevel, + ngrams = ngrams, + noTfidf = noTfidf, + sortBy = sortBy, + noSsr = noSsr, + quiet = !verbose + ) + + override fun getDialog(): AnalyserDialogInterface = Dialog + + override fun isApplicable(resourceToBeParsed: String): Boolean { + if (resourceToBeParsed.isBlank()) return false + + val searchFile = File(resourceToBeParsed.trim()) + if (searchFile.isFile) return isSupportedSource(searchFile) + if (!searchFile.isDirectory) return false + + return searchFile + .walk() + .any { it.isFile && isSupportedSource(it) } + } + + private fun isSupportedSource(file: File): Boolean = SUPPORTED_EXTENSIONS.contains(file.extension.lowercase()) +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/PathUtils.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/PathUtils.kt new file mode 100644 index 0000000000..1ea7b9f4e8 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/PathUtils.kt @@ -0,0 +1,25 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage + +/** + * Splits a file path into its components, handling both Unix and Windows separators. + * + * @param path The file path to split + * @return List of path components + */ +fun splitPath(path: String): List = path.split('/', '\\').filter { it.isNotEmpty() } + +/** + * Joins path components with forward slashes for consistent output. + * + * @param parts The path components to join + * @return The joined path using forward slashes + */ +fun joinPath(vararg parts: String): String = parts.filter { it.isNotEmpty() }.joinToString("/") + +/** + * Normalizes a path to use forward slashes. + * + * @param path The path to normalize + * @return The normalized path with forward slashes + */ +fun normalizePath(path: String): String = path.replace('\\', '/') diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzer.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzer.kt new file mode 100644 index 0000000000..2d87ba1886 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzer.kt @@ -0,0 +1,135 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.AnalysisConfiguration +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.SortBy +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.input.FileScanner +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.DirectoryWordAggregator +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.DomainAnalysisResult +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.WordFrequency +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.FileAnalyzer +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.FileProcessingResult +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.FileProcessor +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.analysis.TfIdfCalculator +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress.ProgressReporter +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress.SilentProgressReporter +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.File + +private val logger = KotlinLogging.logger {} + +class SourceAnalyzer( + private val config: AnalysisConfiguration, + private val fileScanner: FileScanner, + private val fileAnalyzer: FileAnalyzer, + private val fileProcessor: FileProcessor, + private val tfIdfCalculator: TfIdfCalculator, + private val progressReporter: ProgressReporter = SilentProgressReporter +) : WordAnalyzer { + override fun analyze(directoryPath: String): DomainAnalysisResult { + val files = scanFiles(directoryPath) + val processingResult = processFiles(files, directoryPath) + fileAnalyzer.clearCache() + val perFileWordCounts = processingResult.perFileWordCounts + + logSkippedFiles(processingResult.skippedExtensions, perFileWordCounts.size) + logFailedFiles(processingResult.failedFiles) + + val tfidfScores = computeTfidfScores(perFileWordCounts) + val wordsByPath = buildWordsByPath(perFileWordCounts, tfidfScores, config.limit, config.sortBy) + + return DomainAnalysisResult(filePaths = perFileWordCounts.keys.toList(), wordsByPath = wordsByPath) + } + + private fun scanFiles(directoryPath: String): List { + progressReporter.startPhase("Scanning files", total = null) + val files = fileScanner.scan(directoryPath, config.bypassGitignore, config.excludeTests) { progressReporter.advance() } + progressReporter.completePhase() + return files + } + + private fun processFiles(files: List, directoryPath: String): FileProcessingResult { + progressReporter.startPhase("Processing files", total = files.size.toLong()) + val processingResult = processFilesIndividually(files, directoryPath) { progressReporter.advance() } + progressReporter.completePhase() + return processingResult + } + + private fun computeTfidfScores(perFileWordCounts: Map>): Map { + if (!config.enableTfidf) return emptyMap() + if (perFileWordCounts.size < MIN_FILES_FOR_TFIDF) { + logger.warn { + "TF-IDF requires multiple files for meaningful results. Only ${perFileWordCounts.size} file(s) found." + } + } + return tfIdfCalculator.calculate(perFileWordCounts) + } + + private fun logSkippedFiles(skippedExtensions: Map, processedCount: Int) { + if (skippedExtensions.isNotEmpty()) { + val totalSkipped = skippedExtensions.values.sum() + val extensions = skippedExtensions.keys.sorted().joinToString(", ") { ".$it" } + logger.info { + "Analysis complete: $processedCount files processed, " + + "$totalSkipped files skipped (unsupported extensions: $extensions)" + } + } else { + logger.info { "Analysis complete: $processedCount files processed" } + } + } + + private fun logFailedFiles(failedFiles: List) { + if (failedFiles.isEmpty()) return + logger.warn { + "${failedFiles.size} file(s) dropped due to processing errors: " + + failedFiles.sorted().joinToString(", ") + } + } + + private fun processFilesIndividually(files: List, basePath: String, onFileProcessed: (() -> Unit)? = null): FileProcessingResult = + fileProcessor.processFilesIndividually( + files = files, + basePath = basePath, + contentReader = { file -> fileScanner.readFileContent(file).getOrThrow() }, + processor = fileAnalyzer::extractWordsFromFile, + onFileProcessed = onFileProcessed + ) + + private fun buildWordsByPath( + perFileWordCounts: Map>, + tfidfScores: Map, + limit: Int?, + sortBy: SortBy + ): Map> { + val fileWords = + perFileWordCounts.mapValues { (_, wordCounts) -> + val frequencies = + wordCounts.map { (word, count) -> + WordFrequency(text = word, frequency = count, tfidf = tfidfScores[word]) + } + sortAndLimit(frequencies, sortBy, limit) + } + + // Aggregates the per-file words up to every ancestor directory and the root ".". + val wordsPerPath = DirectoryWordAggregator.aggregateDirectories(fileWords, tfidfScores) + + return wordsPerPath.mapValues { (_, words) -> sortAndLimit(words, sortBy, limit) } + } + + private fun sortAndLimit(frequencies: List, sortBy: SortBy, limit: Int?): List { + val sorted = sortWordFrequencies(frequencies, sortBy) + return if (limit != null) sorted.take(limit) else sorted + } + + // `text` ascending is the deterministic tie-break so equal-ranked words keep a stable order and the + // emitted cc.json stays reproducible across runs. + private fun sortWordFrequencies(frequencies: List, sortBy: SortBy): List = when (sortBy) { + SortBy.FREQUENCY -> frequencies.sortedWith(compareByDescending { it.frequency }.thenBy { it.text }) + SortBy.TFIDF -> frequencies.sortedWith(compareByDescending { it.tfidf ?: 0.0 }.thenBy { it.text }) + } + + companion object { + // TF-IDF is only meaningful across multiple documents; below this it degenerates (every term + // appears in "all" files), so we warn but still compute. + private const val MIN_FILES_FOR_TFIDF = 2 + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerFactory.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerFactory.kt new file mode 100644 index 0000000000..2814d982ab --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerFactory.kt @@ -0,0 +1,40 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.AnalysisConfiguration +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.input.FileScanner +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.CoroutineFileProcessor +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.FileAnalyzer +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.PathScopedKeywordProvider +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.StopWordFilter +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.analysis.TfIdfCalculator +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress.ProgressReporter +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress.SilentProgressReporter + +object SourceAnalyzerFactory { + fun create(config: AnalysisConfiguration, progressReporter: ProgressReporter = SilentProgressReporter): SourceAnalyzer { + val fileScanner = FileScanner(config.allowedExtensions) + val pathScopedKeywordProvider = PathScopedKeywordProvider(config.frameworksByPath) + val stopWordFilter = + StopWordFilter( + languageKeywords = config.languageKeywords, + customStopWords = config.customStopWords, + pathScopedKeywordProvider = pathScopedKeywordProvider + ) + val fileAnalyzer = + FileAnalyzer( + stopWordFilter = stopWordFilter, + weights = config.weights, + ngrams = config.ngrams, + enableSsr = config.enableSsr + ) + + return SourceAnalyzer( + config = config, + fileScanner = fileScanner, + fileAnalyzer = fileAnalyzer, + fileProcessor = CoroutineFileProcessor(), + tfIdfCalculator = TfIdfCalculator(), + progressReporter = progressReporter + ) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/WordAnalyzer.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/WordAnalyzer.kt new file mode 100644 index 0000000000..0417c844ea --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/WordAnalyzer.kt @@ -0,0 +1,7 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.DomainAnalysisResult + +interface WordAnalyzer { + fun analyze(directoryPath: String): DomainAnalysisResult +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/AnalysisConfiguration.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/AnalysisConfiguration.kt new file mode 100644 index 0000000000..348a3a6e28 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/AnalysisConfiguration.kt @@ -0,0 +1,31 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.ExtractionWeights +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.Framework +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.LanguageKeywords +import java.nio.file.Path + +data class AnalysisConfiguration( + // File scanning settings + val allowedExtensions: List, + val bypassGitignore: Boolean = false, + val excludeTests: Boolean = false, + // Pipeline settings + val languageKeywords: List = emptyList(), + val weights: ExtractionWeights = ExtractionWeights(), + val ngrams: Int = 1, + val customStopWords: Set = emptySet(), + val frameworksByPath: Map> = emptyMap(), + val enableSsr: Boolean = true, + // Output settings + val limit: Int? = null, + val outputFile: String? = null, + val enableTfidf: Boolean = true, + val sortBy: SortBy = SortBy.FREQUENCY +) { + init { + require(allowedExtensions.isNotEmpty()) { "At least one file extension must be specified" } + require(ngrams >= 1) { "ngrams must be at least 1, got $ngrams" } + require(allowedExtensions.all { it.isNotBlank() }) { "Extensions cannot be blank" } + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ConfigurationBuilder.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ConfigurationBuilder.kt new file mode 100644 index 0000000000..f0f0c6e167 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ConfigurationBuilder.kt @@ -0,0 +1,80 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.ExtractionWeights +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.Framework +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.FrameworkDetector +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.Language +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.LanguageKeywords +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.stopwords.DlcIgnoreParser +import java.nio.file.Path +import java.nio.file.Paths + +class ConfigurationBuilder { + fun build(parsedArgs: ParsedArguments): AnalysisConfiguration { + val directory = + requireNotNull(parsedArgs.directory) { + "Please provide a directory with -d flag" + } + val allowedExtensions = Language.allExtensions() + val frameworksByPath = detectFrameworks(directory) + val languageKeywords = buildLanguageKeywords(parsedArgs.excludeTechnicalStopWords, parsedArgs.stopWordLevel) + val weights = buildExtractionWeights(parsedArgs) + val customStopWords = loadCustomStopWords(directory) + + return AnalysisConfiguration( + allowedExtensions = allowedExtensions, + bypassGitignore = parsedArgs.bypassGitignore, + excludeTests = parsedArgs.excludeTests, + languageKeywords = languageKeywords, + weights = weights, + ngrams = parsedArgs.ngrams, + customStopWords = customStopWords, + frameworksByPath = frameworksByPath, + enableSsr = !parsedArgs.noSsr, + limit = parsedArgs.limit, + outputFile = parsedArgs.output, + enableTfidf = !parsedArgs.noTfidf, + sortBy = parsedArgs.sortBy + ) + } + + private fun detectFrameworks(directory: String): Map> = FrameworkDetector().detectFrameworks(Paths.get(directory)) + + private fun buildLanguageKeywords(excludeTechnicalStopWords: Boolean, stopWordLevel: StopWordLevel) = buildList { + addCoreLanguageKeywords() + addTechnicalStopWordsIfEnabled(excludeTechnicalStopWords, stopWordLevel) + } + + private fun MutableList.addCoreLanguageKeywords() { + add(ResourceKeywords("keywords/java-keywords.txt")) + add(ResourceKeywords("keywords/kotlin-keywords.txt")) + add(ResourceKeywords("keywords/typescript-keywords.txt")) + } + + private fun MutableList.addTechnicalStopWordsIfEnabled( + excludeTechnicalStopWords: Boolean, + stopWordLevel: StopWordLevel + ) { + if (!excludeTechnicalStopWords) { + val stopWords = + when (stopWordLevel) { + StopWordLevel.MINIMAL -> ResourceKeywords("keywords/technical-minimal.txt") + StopWordLevel.MODERATE -> ResourceKeywords("keywords/technical-moderate.txt") + StopWordLevel.AGGRESSIVE -> ResourceKeywords("keywords/technical-aggressive.txt") + } + add(stopWords) + } + } + + private fun buildExtractionWeights(parsedArgs: ParsedArguments) = ExtractionWeights( + identifierWeight = parsedArgs.identifierWeight, + commentWeight = parsedArgs.commentWeight, + stringWeight = parsedArgs.stringWeight + ) + + private fun loadCustomStopWords(directory: String): Set { + val parser = DlcIgnoreParser() + return parser.loadCustomStopWords(Paths.get(directory)) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ParsedArguments.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ParsedArguments.kt new file mode 100644 index 0000000000..75dcf6794c --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ParsedArguments.kt @@ -0,0 +1,19 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli + +data class ParsedArguments( + val directory: String?, + val limit: Int?, + val bypassGitignore: Boolean, + val excludeTests: Boolean, + val output: String?, + val identifierWeight: Int, + val commentWeight: Int, + val stringWeight: Int, + val excludeTechnicalStopWords: Boolean, + val stopWordLevel: StopWordLevel, + val ngrams: Int, + val noTfidf: Boolean = false, + val sortBy: SortBy = SortBy.FREQUENCY, + val noSsr: Boolean = false, + val quiet: Boolean = false +) diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/SortBy.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/SortBy.kt new file mode 100644 index 0000000000..ed1815ab0e --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/SortBy.kt @@ -0,0 +1,6 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli + +enum class SortBy { + FREQUENCY, + TFIDF +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/StopWordLevel.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/StopWordLevel.kt new file mode 100644 index 0000000000..09be692441 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/StopWordLevel.kt @@ -0,0 +1,7 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli + +enum class StopWordLevel { + MINIMAL, + MODERATE, + AGGRESSIVE +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileFilter.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileFilter.kt new file mode 100644 index 0000000000..7849ad98e9 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileFilter.kt @@ -0,0 +1,9 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.input + +import java.io.File + +class FileFilter(private val allowedExtensions: List) { + fun matchesExtension(file: File): Boolean = allowedExtensions.any { ext -> + file.name.endsWith(".$ext", ignoreCase = true) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileScanner.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileScanner.kt new file mode 100644 index 0000000000..786f614475 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileScanner.kt @@ -0,0 +1,62 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.input + +import de.maibornwolff.codecharta.analysers.analyserinterface.gitignore.GitignoreHandler +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.File +import java.nio.charset.Charset + +private val logger = KotlinLogging.logger {} + +class FileScanner(private val allowedExtensions: List) { + private val fileFilter = FileFilter(allowedExtensions) + private val testFileDetector = TestFileDetector() + + fun scan( + directoryPath: String, + bypassGitignore: Boolean = false, + excludeTests: Boolean = false, + onFileFound: (() -> Unit)? = null + ): List { + val directory = File(directoryPath) + if (!directory.exists() || !directory.isDirectory) { + logger.warn { "Directory does not exist or is not a directory: $directoryPath" } + return emptyList() + } + + return if (bypassGitignore) { + scanWithoutGitignore(directory, excludeTests, onFileFound) + } else { + scanWithGitignore(directory, excludeTests, onFileFound) + } + } + + private fun scanWithoutGitignore(directory: File, excludeTests: Boolean, onFileFound: (() -> Unit)?): List = directory + .walkTopDown() + .filter { it.isFile } + .filter { file -> fileFilter.matchesExtension(file) } + .filter { file -> !excludeTests || !testFileDetector.isTestFile(file) } + .onEach { onFileFound?.invoke() } + .toList() + + private fun scanWithGitignore(rootDirectory: File, excludeTests: Boolean, onFileFound: (() -> Unit)?): List { + val gitignoreHandler = GitignoreHandler(rootDirectory) + + return rootDirectory + .walkTopDown() + .onEnter { dir -> !gitignoreHandler.shouldExclude(dir) } + .filter { it.isFile } + .filter { file -> !gitignoreHandler.shouldExclude(file) } + .filter { file -> fileFilter.matchesExtension(file) } + .filter { file -> !excludeTests || !testFileDetector.isTestFile(file) } + .onEach { onFileFound?.invoke() } + .toList() + } + + fun readFileContent(file: File, charset: Charset = Charsets.UTF_8): Result = runCatching { + require(file.exists()) { "File does not exist: ${file.absolutePath}" } + require(file.isFile) { "Path is not a file: ${file.absolutePath}" } + file.readText(charset) + }.onFailure { error -> + logger.error(error) { "Failed to read file: ${file.absolutePath}" } + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/TestFileDetector.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/TestFileDetector.kt new file mode 100644 index 0000000000..75aae82d70 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/TestFileDetector.kt @@ -0,0 +1,61 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.input + +import java.io.File + +class TestFileDetector { + private val testDirectories = + setOf( + "test", + "tests", + "__tests__", + "spec", + "specs" + ) + + fun isTestFile(file: File): Boolean = isInTestDirectory(file) || hasTestFileName(file) + + private fun isInTestDirectory(file: File): Boolean { + val normalizedPath = file.absolutePath.replace('\\', '/') + return testDirectories.any { testDir -> + normalizedPath.contains("/$testDir/") + } + } + + private fun hasTestFileName(file: File): Boolean { + val name = file.name + val nameWithoutExtension = name.substringBeforeLast('.') + + return when { + // Kotlin: UserTest.kt, ServiceTest.kts + matchesKotlinTestPattern(name, nameWithoutExtension) -> true + + // TypeScript/JavaScript: user.test.ts, service.spec.js + matchesTypeScriptJavaScriptTestPattern(name) -> true + + // Java: UserTest.java + matchesJavaTestPattern(name, nameWithoutExtension) -> true + + // Python: test_user.py, user_test.py + matchesPythonTestPattern(name, nameWithoutExtension) -> true + + else -> false + } + } + + private fun matchesKotlinTestPattern(name: String, nameWithoutExtension: String): Boolean = + (name.endsWith(".kt", ignoreCase = true) || name.endsWith(".kts", ignoreCase = true)) && + nameWithoutExtension.endsWith("Test") + + private fun matchesTypeScriptJavaScriptTestPattern(name: String): Boolean { + val jsExtensions = listOf(".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs", ".cts", ".mts") + val hasJsExtension = jsExtensions.any { ext -> name.endsWith(ext, ignoreCase = true) } + + return hasJsExtension && (name.contains(".test.") || name.contains(".spec.")) + } + + private fun matchesJavaTestPattern(name: String, nameWithoutExtension: String): Boolean = + name.endsWith(".java", ignoreCase = true) && nameWithoutExtension.endsWith("Test") + + private fun matchesPythonTestPattern(name: String, nameWithoutExtension: String): Boolean = name.endsWith(".py", ignoreCase = true) && + (nameWithoutExtension.startsWith("test_") || nameWithoutExtension.endsWith("_test")) +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DirectoryWordAggregator.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DirectoryWordAggregator.kt new file mode 100644 index 0000000000..46f0b0b53e --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DirectoryWordAggregator.kt @@ -0,0 +1,51 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.joinPath +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.splitPath + +object DirectoryWordAggregator { + fun aggregateDirectories( + fileWords: Map>, + tfidfScores: Map = emptyMap() + ): Map> { + val result = mutableMapOf>() + + // Add all file-level word frequencies + fileWords.forEach { (filePath, words) -> + result[filePath] = words.associate { it.text to it.frequency }.toMutableMap() + } + + // Aggregate words for each directory level + fileWords.keys.forEach { filePath -> + val words = fileWords[filePath] ?: emptyList() + + // Get all parent directories + val directories = getParentDirectories(filePath) + + // Aggregate words to each parent directory + directories.forEach { dirPath -> + val dirWords = result.getOrPut(dirPath) { mutableMapOf() } + words.forEach { word -> + dirWords[word.text] = (dirWords[word.text] ?: 0) + word.frequency + } + } + } + + // Convert to List with TF-IDF scores (caller handles sorting) + return result.mapValues { (_, wordMap) -> + wordMap.map { (text, freq) -> WordFrequency(text = text, frequency = freq, tfidf = tfidfScores[text]) } + } + } + + private fun getParentDirectories(filePath: String): List { + val parts = splitPath(filePath) + + // Build list: root + intermediate directories + return buildList { + add(".") + for (i in 0 until parts.size - 1) { + add(joinPath(*parts.subList(0, i + 1).toTypedArray())) + } + } + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainAnalysisResult.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainAnalysisResult.kt new file mode 100644 index 0000000000..5ee97539c6 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainAnalysisResult.kt @@ -0,0 +1,12 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output + +/** + * Structured result of a domain-language analysis, ready to be turned into a cc.json `domain` lens. + * + * @property filePaths relative paths (from the scan root) of every analyzed file — the leaves of the + * files tree. + * @property wordsByPath the domain words for every file path AND every aggregated directory path + * (including the root `"."`), already sorted and limited. Keys line up with [filePaths] for leaves + * and with their ancestor directories for folders. + */ +data class DomainAnalysisResult(val filePaths: List, val wordsByPath: Map>) diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainProjectGenerator.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainProjectGenerator.kt new file mode 100644 index 0000000000..3a721327f1 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainProjectGenerator.kt @@ -0,0 +1,95 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import de.maibornwolff.codecharta.analysers.filters.mergefilter.MergeFilter +import de.maibornwolff.codecharta.model.LensSet +import de.maibornwolff.codecharta.model.MutableNode +import de.maibornwolff.codecharta.model.NodeId +import de.maibornwolff.codecharta.model.NodeType +import de.maibornwolff.codecharta.model.Path +import de.maibornwolff.codecharta.model.PathFactory +import de.maibornwolff.codecharta.model.Project +import de.maibornwolff.codecharta.model.ProjectBuilder + +/** + * Builds a cc.json 2.0 [Project] from a [DomainAnalysisResult]: a standard `files` tree plus the + * reserved opaque `domain` lens (`LensSet.DOMAIN_KEY`). + * + * The lens is a bare `{ "": [{text, frequency, tfidf?}, ...] }` map — no envelope. Each key is + * a cc.json 2.0 node id computed with [NodeId.fromSegments] using the SAME path segments and + * [NodeType] the 2.0 writer derives for the files tree, so the lens resolves against the emitted ids + * (File for leaves, Folder for directories and the root). + */ +class DomainProjectGenerator(private val projectBuilder: ProjectBuilder = ProjectBuilder()) { + fun generate(result: DomainAnalysisResult, pipedProject: Project? = null): Project { + addFilesAsNodes(result.filePaths) + val project = + projectBuilder + .withOpaqueLenses(mapOf(LensSet.DOMAIN_KEY to buildDomainLens(result))) + .build() + + return if (pipedProject != null) MergeFilter.mergePipedWithCurrentProject(pipedProject, project) else project + } + + private fun addFilesAsNodes(filePaths: List) { + filePaths.forEach { filePath -> + val segments = segmentsOf(filePath) + if (segments.isEmpty()) return@forEach + val fileName = segments.last() + val parentPath = Path(segments.dropLast(1)) + projectBuilder.insertByPath(parentPath, MutableNode(fileName, NodeType.File)) + } + } + + private fun buildDomainLens(result: DomainAnalysisResult): JsonObject { + val fileKeys = result.filePaths.toSet() + val domainLens = JsonObject() + + result.wordsByPath.entries + .map { (path, words) -> + val segments = segmentsOf(path) + val type = if (path in fileKeys) NodeType.File else NodeType.Folder + DomainNode(NodeId.fromSegments(segments, type), segments, words) + } + // Deterministic, breadth-first key order (root, then by depth then name) keeps the + // serialized body byte-stable so the cc.json checksum is reproducible. + .sortedWith(compareBy({ it.segments.size }, { it.segments.joinToString(SEGMENT_SEPARATOR) })) + .forEach { node -> domainLens.add(node.id, toWordArray(node.words)) } + + return domainLens + } + + private fun toWordArray(words: List): JsonArray { + val array = JsonArray() + words.forEach { word -> + val wordObject = JsonObject() + wordObject.addProperty(TEXT_KEY, word.text) + wordObject.addProperty(FREQUENCY_KEY, word.frequency) + // Preserve the historical omit-when-null behavior: no `tfidf` key rather than `"tfidf": null`. + if (word.tfidf != null) { + wordObject.addProperty(TFIDF_KEY, word.tfidf) + } + array.add(wordObject) + } + return array + } + + // PathFactory performs the structural half of NodeId's canonicalization (drops "" and ".", + // collapses ".."), so "." maps to the empty (root) segment list and file/dir paths yield the exact + // segments the files-tree writer uses to derive node ids. extractOSIndependentPath detects the + // separator so backslash-keyed Windows file paths split the same way the forward-slashed directory + // keys do — keeping the tree structure and the domain-lens keys aligned on every platform. + private fun segmentsOf(path: String): List = PathFactory.extractOSIndependentPath(path).edgesList + + private data class DomainNode(val id: String, val segments: List, val words: List) + + companion object { + private const val SEGMENT_SEPARATOR = "/" + + // Word-entry field names — the `domain` lens wire schema, single-sourced. + private const val TEXT_KEY = "text" + private const val FREQUENCY_KEY = "frequency" + private const val TFIDF_KEY = "tfidf" + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/WordFrequency.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/WordFrequency.kt new file mode 100644 index 0000000000..174093784b --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/WordFrequency.kt @@ -0,0 +1,11 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output + +/** + * Represents a word and its frequency in the analyzed code. + * + * @property text The word text + * @property frequency The number of occurrences + * @property tfidf Optional TF-IDF score for ranking word importance. When `null` it is omitted from + * the emitted `domain` lens entirely (see [DomainProjectGenerator]). + */ +data class WordFrequency(val text: String, val frequency: Int, val tfidf: Double? = null) diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/CoroutineFileProcessor.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/CoroutineFileProcessor.kt new file mode 100644 index 0000000000..21de819e3d --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/CoroutineFileProcessor.kt @@ -0,0 +1,54 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue + +private val logger = KotlinLogging.logger {} + +class CoroutineFileProcessor : FileProcessor { + override fun processFilesIndividually( + files: List, + basePath: String, + contentReader: (File) -> String, + processor: (File, String) -> FileResult, + onFileProcessed: (() -> Unit)? + ): FileProcessingResult { + val fileWordCounts = ConcurrentHashMap>() + val skippedExtensions = ConcurrentHashMap() + val failedFiles = ConcurrentLinkedQueue() + + runBlocking(Dispatchers.IO) { + files + .map { file -> + async { + val relativePath = file.toRelativeString(File(basePath)) + try { + when (val result = processor(file, contentReader(file))) { + is FileResult.Processed -> fileWordCounts[relativePath] = result.words + is FileResult.Skipped -> skippedExtensions.merge(result.extension, 1, Int::plus) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + failedFiles.add(relativePath) + logger.warn(error) { "Skipping file due to processing error: $relativePath" } + } + onFileProcessed?.invoke() + } + }.awaitAll() + } + + return FileProcessingResult( + perFileWordCounts = fileWordCounts, + skippedExtensions = skippedExtensions, + failedFiles = failedFiles.toList() + ) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/ExtractionWeights.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/ExtractionWeights.kt new file mode 100644 index 0000000000..121c5ba72b --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/ExtractionWeights.kt @@ -0,0 +1,9 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +data class ExtractionWeights(val identifierWeight: Int = 3, val commentWeight: Int = 2, val stringWeight: Int = 1) { + init { + require(identifierWeight > 0) { "Identifier weight must be positive, got $identifierWeight" } + require(commentWeight > 0) { "Comment weight must be positive, got $commentWeight" } + require(stringWeight > 0) { "String weight must be positive, got $stringWeight" } + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileAnalyzer.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileAnalyzer.kt new file mode 100644 index 0000000000..61b22448f8 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileAnalyzer.kt @@ -0,0 +1,40 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.SourceCodePipeline +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Analyzes source code files and extracts word frequencies. + * + * Supports Kotlin, TypeScript, and JavaScript using tree-sitter AST parsing. + * Unsupported file types return [FileResult.Skipped] with the extension. + * + * Framework-specific keywords are filtered based on the file's location + * relative to detected framework directories. + */ +class FileAnalyzer( + private val stopWordFilter: StopWordFilter, + private val weights: ExtractionWeights = ExtractionWeights(), + private val ngrams: Int = 1, + private val enableSsr: Boolean = true +) { + private val pipelines = ConcurrentHashMap() + + private fun getPipeline(language: Language): SourceCodePipeline = pipelines.computeIfAbsent(language) { + SourceCodePipeline(language, weights, ngrams, stopWordFilter, enableSsr) + } + + fun extractWordsFromFile(file: File, content: String): FileResult { + val language = + Language.fromExtension(file.extension) + ?: return FileResult.Skipped(file.extension.lowercase()) + val filePath = file.toPath().toAbsolutePath() + val words = getPipeline(language).process(content, filePath) + return FileResult.Processed(words) + } + + fun clearCache() { + stopWordFilter.clearCache() + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileProcessor.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileProcessor.kt new file mode 100644 index 0000000000..b80471d9e0 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileProcessor.kt @@ -0,0 +1,25 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import java.io.File + +/** + * Result of processing files individually, including skipped and failed file information. + * + * @property skippedExtensions files intentionally not analyzed, grouped by unsupported extension. + * @property failedFiles relative paths of files dropped because processing threw an error. + */ +data class FileProcessingResult( + val perFileWordCounts: Map>, + val skippedExtensions: Map, + val failedFiles: List = emptyList() +) + +interface FileProcessor { + fun processFilesIndividually( + files: List, + basePath: String, + contentReader: (File) -> String, + processor: (File, String) -> FileResult, + onFileProcessed: (() -> Unit)? = null + ): FileProcessingResult +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileResult.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileResult.kt new file mode 100644 index 0000000000..b763c63002 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileResult.kt @@ -0,0 +1,13 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +/** + * Result of processing a single file. + * + * Used to distinguish between files that were successfully processed + * and files that were skipped due to unsupported extensions. + */ +sealed class FileResult { + data class Processed(val words: Map) : FileResult() + + data class Skipped(val extension: String) : FileResult() +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FrameworkDetector.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FrameworkDetector.kt new file mode 100644 index 0000000000..26733d5153 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FrameworkDetector.kt @@ -0,0 +1,135 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import java.nio.file.Path +import kotlin.io.path.readText + +private val logger = KotlinLogging.logger {} + +enum class Framework { + REACT, + ANGULAR, + ASPNET, + ENTITYFRAMEWORK +} + +class FrameworkDetector { + fun detectFrameworks(directoryPath: Path): Map> { + val frameworksByPath = mutableMapOf>() + + // Detect JavaScript/TypeScript frameworks from package.json + detectJavaScriptFrameworks(directoryPath).forEach { (path, frameworks) -> + frameworksByPath.merge(path, frameworks) { existing, new -> existing + new } + } + + // Detect C# frameworks from .csproj files + detectCSharpFrameworks(directoryPath).forEach { (path, frameworks) -> + frameworksByPath.merge(path, frameworks) { existing, new -> existing + new } + } + + return frameworksByPath + } + + private fun detectJavaScriptFrameworks(directoryPath: Path): Map> { + val packageJsonFiles = findPackageJsonFiles(directoryPath) + + if (packageJsonFiles.isEmpty()) { + return emptyMap() + } + + val frameworksByPath = mutableMapOf>() + packageJsonFiles.forEach { packageJsonPath -> + try { + val dependencies = extractAllDependencies(packageJsonPath) + val frameworks = identifyJavaScriptFrameworks(dependencies) + if (frameworks.isNotEmpty()) { + frameworksByPath[packageJsonPath.parent] = frameworks + } + } catch (e: Exception) { + logger.warn(e) { "Failed to parse package.json at $packageJsonPath, skipping framework detection" } + } + } + return frameworksByPath + } + + private fun findPackageJsonFiles(directoryPath: Path): List = directoryPath + .toFile() + .walkTopDown() + .filter { it.name == "package.json" && !it.path.contains("node_modules") } + .map { it.toPath() } + .toList() + + private fun detectCSharpFrameworks(directoryPath: Path): Map> { + val csprojFiles = findCsprojFiles(directoryPath) + if (csprojFiles.isEmpty()) { + return emptyMap() + } + + val frameworksByPath = mutableMapOf>() + csprojFiles.forEach { csprojFile -> + try { + val packageReferences = extractPackageReferences(csprojFile) + val frameworks = identifyCSharpFrameworks(packageReferences) + if (frameworks.isNotEmpty()) { + val directory = csprojFile.parent + // Merge with existing frameworks if directory already has some + val existing = frameworksByPath[directory] ?: emptySet() + frameworksByPath[directory] = existing + frameworks + } + } catch (e: Exception) { + logger.warn(e) { "Failed to parse .csproj at $csprojFile, skipping framework detection" } + } + } + return frameworksByPath + } + + private fun findCsprojFiles(directoryPath: Path): List = directoryPath + .toFile() + .walkTopDown() + .filter { it.isFile && it.extension == "csproj" } + .map { it.toPath() } + .toList() + + private fun extractPackageReferences(csprojPath: Path): Set { + val csprojContent = csprojPath.readText() + val packageReferencePattern = Regex(""" { + val packageJsonContent = packageJsonPath.readText() + val jsonObject = Json.parseToJsonElement(packageJsonContent).jsonObject + val dependencies = extractDependencyKeys(jsonObject, "dependencies") + val devDependencies = extractDependencyKeys(jsonObject, "devDependencies") + return dependencies + devDependencies + } + + private fun identifyJavaScriptFrameworks(dependencies: Set) = buildSet { + if (dependencies.contains("react")) { + add(Framework.REACT) + } + if (dependencies.contains("@angular/core")) { + add(Framework.ANGULAR) + } + } + + private fun identifyCSharpFrameworks(packageReferences: Set) = buildSet { + if (packageReferences.any { it.startsWith("Microsoft.AspNetCore") || it.startsWith("Microsoft.AspNet") }) { + add(Framework.ASPNET) + } + if (packageReferences.any { it.startsWith("Microsoft.EntityFrameworkCore") || it.startsWith("EntityFramework") }) { + add(Framework.ENTITYFRAMEWORK) + } + } + + private fun extractDependencyKeys(jsonObject: JsonObject, fieldName: String): Set = jsonObject[fieldName] + ?.jsonObject + ?.keys + ?: emptySet() +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/Language.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/Language.kt new file mode 100644 index 0000000000..92cf4402d6 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/Language.kt @@ -0,0 +1,107 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.LanguageKeywords +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import de.maibornwolff.treesitter.excavationsite.api.Language as LibraryLanguage + +/** + * Supported programming languages for source code analysis. + * + * Uses tree-sitter AST parsing via TreeSitterLibrary for extraction. + */ +enum class Language(val keywordFilter: LanguageKeywords, val libraryLanguage: LibraryLanguage, private val extensions: Set) { + KOTLIN( + keywordFilter = ResourceKeywords("keywords/kotlin-keywords.txt"), + libraryLanguage = LibraryLanguage.KOTLIN, + extensions = setOf("kt", "kts") + ), + TYPESCRIPT( + keywordFilter = ResourceKeywords("keywords/typescript-keywords.txt"), + libraryLanguage = LibraryLanguage.TYPESCRIPT, + extensions = setOf("ts", "tsx", "cts", "mts") + ), + JAVASCRIPT( + keywordFilter = ResourceKeywords("keywords/javascript-keywords.txt"), + libraryLanguage = LibraryLanguage.JAVASCRIPT, + extensions = setOf("js", "jsx", "mjs", "cjs") + ), + JAVA( + keywordFilter = ResourceKeywords("keywords/java-keywords.txt"), + libraryLanguage = LibraryLanguage.JAVA, + extensions = setOf("java") + ), + PYTHON( + keywordFilter = ResourceKeywords("keywords/python-keywords.txt"), + libraryLanguage = LibraryLanguage.PYTHON, + extensions = setOf("py", "pyw") + ), + CSHARP( + keywordFilter = ResourceKeywords("keywords/csharp-keywords.txt"), + libraryLanguage = LibraryLanguage.CSHARP, + extensions = setOf("cs") + ), + GO( + keywordFilter = ResourceKeywords("keywords/go-keywords.txt"), + libraryLanguage = LibraryLanguage.GO, + extensions = setOf("go") + ), + C( + keywordFilter = ResourceKeywords("keywords/c-keywords.txt"), + libraryLanguage = LibraryLanguage.C, + extensions = setOf("c", "h") + ), + CPP( + keywordFilter = ResourceKeywords("keywords/cpp-keywords.txt"), + libraryLanguage = LibraryLanguage.CPP, + extensions = setOf("cpp", "cc", "cxx", "hpp", "hxx", "h++") + ), + PHP( + keywordFilter = ResourceKeywords("keywords/php-keywords.txt"), + libraryLanguage = LibraryLanguage.PHP, + extensions = setOf("php", "phtml") + ), + RUBY( + keywordFilter = ResourceKeywords("keywords/ruby-keywords.txt"), + libraryLanguage = LibraryLanguage.RUBY, + extensions = setOf("rb", "rake", "gemspec") + ), + SWIFT( + keywordFilter = ResourceKeywords("keywords/swift-keywords.txt"), + libraryLanguage = LibraryLanguage.SWIFT, + extensions = setOf("swift") + ), + BASH( + keywordFilter = ResourceKeywords("keywords/bash-keywords.txt"), + libraryLanguage = LibraryLanguage.BASH, + extensions = setOf("sh", "bash", "zsh") + ), + OBJC( + keywordFilter = ResourceKeywords("keywords/objc-keywords.txt"), + libraryLanguage = LibraryLanguage.OBJECTIVE_C, + extensions = setOf("m", "mm") + ), + VUE( + keywordFilter = ResourceKeywords("keywords/vue-keywords.txt"), + libraryLanguage = LibraryLanguage.VUE, + extensions = setOf("vue") + ), + ABL( + keywordFilter = ResourceKeywords("keywords/abl-keywords.txt"), + libraryLanguage = LibraryLanguage.ABL, + extensions = setOf("p", "cls", "w") + ), + RUST( + keywordFilter = ResourceKeywords("keywords/rust-keywords.txt"), + libraryLanguage = LibraryLanguage.RUST, + extensions = setOf("rs") + ) + ; + + companion object { + fun fromExtension(extension: String): Language? = entries.find { language -> + extension.lowercase() in language.extensions + } + + fun allExtensions(): List = entries.flatMap { it.extensions.toList() } + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedKeywordProvider.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedKeywordProvider.kt new file mode 100644 index 0000000000..440af76c2b --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedKeywordProvider.kt @@ -0,0 +1,53 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.LanguageKeywords +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import java.nio.file.Path + +/** + * Provides framework-specific keywords based on file location. + * + * Maintains a registry of detected frameworks by path and determines + * which framework keywords apply to each file based on its location + * relative to the framework directories. + */ +class PathScopedKeywordProvider(private val frameworksByPath: Map>) { + private val keywordCache = mutableMapOf() + + companion object { + /** + * A null-object instance that provides no framework keywords. + * Use this instead of null when no path-scoped filtering is needed. + */ + val NONE: PathScopedKeywordProvider = PathScopedKeywordProvider(emptyMap()) + } + + fun getFrameworkKeywordsForFile(filePath: Path): List { + val applicableFrameworks = findApplicableFrameworks(filePath) + return applicableFrameworks.mapNotNull { framework -> + keywordCache.getOrPut(framework) { + when (framework) { + Framework.ANGULAR -> ResourceKeywords("keywords/angular-keywords.txt") + Framework.REACT -> ResourceKeywords("keywords/react-keywords.txt") + Framework.ASPNET -> ResourceKeywords("keywords/aspnet-keywords.txt") + Framework.ENTITYFRAMEWORK -> ResourceKeywords("keywords/entityframework-keywords.txt") + } + } + } + } + + private fun findApplicableFrameworks(filePath: Path): Set { + for ((frameworkDir, frameworks) in frameworksByPath) { + if (isFileUnderDirectory(filePath, frameworkDir)) { + return frameworks + } + } + return emptySet() + } + + private fun isFileUnderDirectory(filePath: Path, directory: Path): Boolean { + val normalizedFile = filePath.normalize() + val normalizedDir = directory.normalize() + return normalizedFile.startsWith(normalizedDir) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/StopWordFilter.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/StopWordFilter.kt new file mode 100644 index 0000000000..940fa38fa0 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/StopWordFilter.kt @@ -0,0 +1,56 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.LanguageKeywords +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywordLoader +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap + +class StopWordFilter( + private val languageKeywords: List = emptyList(), + private val customStopWords: Set = emptySet(), + private val keywordLoader: ResourceKeywordLoader = ResourceKeywordLoader(), + private val pathScopedKeywordProvider: PathScopedKeywordProvider = PathScopedKeywordProvider.NONE +) { + private val stopWords: Set by lazy { + keywordLoader.loadFromResource("stopwords/english-stopwords.txt") + } + + private val allExcludedWords: Set by lazy { + val languageKeywordSet = languageKeywords.flatMap { it.getKeywords() }.toSet() + stopWords + languageKeywordSet + customStopWords + } + + private val pathExcludedWordsCache = ConcurrentHashMap>() + + fun filter(words: List): List = words.filter { it !in allExcludedWords } + + fun filter(words: List, filePath: Path): List { + val excludedWords = getExcludedWordsForPath(filePath) + return words.filter { it !in excludedWords } + } + + fun isExcluded(word: String): Boolean = isExcludedFrom(word, allExcludedWords) + + fun isExcluded(word: String, filePath: Path): Boolean = isExcludedFrom(word, getExcludedWordsForPath(filePath)) + + private fun isExcludedFrom(word: String, excludedWords: Set): Boolean { + if (word in excludedWords) return true + if (word.contains(' ')) { + return word.split(' ').any { it in excludedWords } + } + return false + } + + private fun getExcludedWordsForPath(filePath: Path): Set = pathExcludedWordsCache.computeIfAbsent(filePath) { path -> + val frameworkKeywords = + pathScopedKeywordProvider + .getFrameworkKeywordsForFile(path) + .flatMap { it.getKeywords() } + .toSet() + allExcludedWords + frameworkKeywords + } + + fun clearCache() { + pathExcludedWordsCache.clear() + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/analysis/TfIdfCalculator.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/analysis/TfIdfCalculator.kt new file mode 100644 index 0000000000..9d502b0979 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/analysis/TfIdfCalculator.kt @@ -0,0 +1,54 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.analysis + +import kotlin.math.log10 + +/** + * Calculates TF-IDF scores using corpus-level aggregation. + * + * Design: Aggregates term frequencies across all files rather than + * normalizing per-file. This emphasizes globally distinctive terms, + * useful for identifying domain concepts in specific code areas. + */ +class TfIdfCalculator { + fun calculate(perFileFrequencies: Map>): Map { + val totalDocuments = perFileFrequencies.size + + if (totalDocuments <= 1) { + return emptyMap() + } + + val documentFrequency = countDocumentFrequency(perFileFrequencies) + val termFrequency = sumTermFrequency(perFileFrequencies) + + return termFrequency.mapValues { (term, tf) -> + val df = documentFrequency[term] ?: 0 + val idf = calculateIdf(totalDocuments, df) + tf * idf + } + } + + private fun countDocumentFrequency(perFileFrequencies: Map>): Map { + val documentFrequency = mutableMapOf() + for ((_, wordCounts) in perFileFrequencies) { + for (term in wordCounts.keys) { + documentFrequency[term] = (documentFrequency[term] ?: 0) + 1 + } + } + return documentFrequency + } + + private fun sumTermFrequency(perFileFrequencies: Map>): Map { + val termFrequency = mutableMapOf() + for ((_, wordCounts) in perFileFrequencies) { + for ((term, count) in wordCounts) { + termFrequency[term] = (termFrequency[term] ?: 0) + count + } + } + return termFrequency + } + + private fun calculateIdf(totalDocuments: Int, documentFrequency: Int): Double { + if (documentFrequency == 0) return 0.0 + return log10(totalDocuments.toDouble() / documentFrequency) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/LanguageKeywords.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/LanguageKeywords.kt new file mode 100644 index 0000000000..7270859a8e --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/LanguageKeywords.kt @@ -0,0 +1,5 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords + +interface LanguageKeywords { + fun getKeywords(): Set +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordLoader.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordLoader.kt new file mode 100644 index 0000000000..fb18317d78 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordLoader.kt @@ -0,0 +1,16 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords + +class ResourceKeywordLoader { + fun loadFromResource(resourcePath: String): Set { + val inputStream = + this::class.java.classLoader.getResourceAsStream(resourcePath) + ?: throw IllegalArgumentException("Resource file not found: $resourcePath") + + return inputStream.bufferedReader().useLines { lines -> + lines + .map { it.trim() } + .filterNot { it.isEmpty() || it.startsWith("#") } + .toSet() + } + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywords.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywords.kt new file mode 100644 index 0000000000..a1185be673 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywords.kt @@ -0,0 +1,17 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords + +/** + * A parameterized keyword provider that loads keywords from a resource file. + * + * This class replaces all individual language keyword classes (KotlinKeywords, JavaKeywords, etc.) + * with a single reusable implementation that takes the resource path as a constructor parameter. + * + * @param resourcePath The path to the keyword resource file (e.g., "keywords/kotlin-keywords.txt") + */ +class ResourceKeywords(private val resourcePath: String) : LanguageKeywords { + private val cachedKeywords: Set by lazy { + ResourceKeywordLoader().loadFromResource(resourcePath) + } + + override fun getKeywords(): Set = cachedKeywords +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/ExtractedText.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/ExtractedText.kt new file mode 100644 index 0000000000..6b816c995d --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/ExtractedText.kt @@ -0,0 +1,15 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline + +/** + * Represents text extracted from source code with its extraction context. + * + * @property text The extracted text (identifier, comment, or string literal) + * @property context The extraction context (IDENTIFIER, COMMENT, or STRING) + */ +data class ExtractedText(val text: String, val context: ExtractionContext) + +enum class ExtractionContext { + IDENTIFIER, + COMMENT, + STRING +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/SourceCodePipeline.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/SourceCodePipeline.kt new file mode 100644 index 0000000000..1df4b83f14 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/SourceCodePipeline.kt @@ -0,0 +1,84 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.ExtractionWeights +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.Language +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.StopWordFilter +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages.AggregateStage +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages.ExtractStage +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages.FilterStage +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages.NgramsStage +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages.SplitStage +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages.WeightStage +import java.nio.file.Path + +/** + * Source code analysis pipeline using tree-sitter. + * + * This pipeline implements a sequential architecture: + * 1. Extract: Extract identifiers, comments, and strings using TreeSitterLibrary + * 2. Weight: Apply weights based on extraction context + * 3. Split: Split identifiers/text into individual words + * 4. N-grams: Generate n-grams from identifier words + * 5. Filter: Remove stop words (with optional path-scoped framework filtering) + * 6. Aggregate: Sum weights to produce frequency map + */ +class SourceCodePipeline( + language: Language, + weights: ExtractionWeights = ExtractionWeights(), + ngrams: Int = 1, + private val stopWordFilter: StopWordFilter, + enableSsr: Boolean = true +) { + private val extractStage = ExtractStage(language.libraryLanguage) + private val weightStage = WeightStage(weights) + private val splitStage = SplitStage(ngrams) + private val ngramsStage = NgramsStage(ngrams, enableSsr) + private val filterStage = FilterStage(stopWordFilter) + private val aggregateStage = AggregateStage() + + /** + * Process source code through the pipeline. + * + * @param sourceCode The source code to analyze + * @return Map of word to frequency count + */ + fun process(sourceCode: String): Map = processWithOptionalPath(sourceCode, filePath = null) + + /** + * Process source code through the pipeline with path-scoped framework filtering. + * + * @param sourceCode The source code to analyze + * @param filePath The path of the file being processed (used for path-scoped filtering) + * @return Map of word to frequency count + */ + fun process(sourceCode: String, filePath: Path): Map = processWithOptionalPath(sourceCode, filePath) + + private fun processWithOptionalPath(sourceCode: String, filePath: Path?): Map { + if (sourceCode.isBlank()) { + return emptyMap() + } + + // Stage 1: Extract + val extractedTexts = extractStage.extract(sourceCode) + + // Stage 2: Weight + val weightedTexts = weightStage.weight(extractedTexts) + + // Stage 3: Split + val splitResults = splitStage.split(weightedTexts) + + // Stage 4: N-grams + val ngramTexts = ngramsStage.generateNgrams(splitResults) + + // Stage 5: Filter (with optional path-scoped framework filtering) + val filteredTexts = + if (filePath != null) { + filterStage.filter(ngramTexts, filePath) + } else { + filterStage.filter(ngramTexts) + } + + // Stage 6: Aggregate + return aggregateStage.aggregate(filteredTexts) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/WeightedText.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/WeightedText.kt new file mode 100644 index 0000000000..aadf26e4c3 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/WeightedText.kt @@ -0,0 +1,10 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline + +/** + * Represents text with an associated weight and extraction context. + * + * @property text The text content + * @property weight The numeric weight applied to this text + * @property context The extraction context (used by split stage) + */ +data class WeightedText(val text: String, val weight: Int, val context: ExtractionContext) diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/AggregateStage.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/AggregateStage.kt new file mode 100644 index 0000000000..7c76488085 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/AggregateStage.kt @@ -0,0 +1,20 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.WeightedText + +/** + * Aggregate stage: Converts a list of weighted text to a frequency map. + * + * This stage sums up all weights for each unique text to produce the final word frequency counts. + */ +class AggregateStage { + fun aggregate(weightedTexts: List): Map { + val wordCounts = mutableMapOf() + + weightedTexts.forEach { weighted -> + wordCounts.merge(weighted.text, weighted.weight, Int::plus) + } + + return wordCounts + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/ExtractStage.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/ExtractStage.kt new file mode 100644 index 0000000000..b9bc792a5a --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/ExtractStage.kt @@ -0,0 +1,34 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.ExtractedText +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.ExtractionContext +import de.maibornwolff.treesitter.excavationsite.api.Language +import de.maibornwolff.treesitter.excavationsite.api.TreeSitterExtraction +import de.maibornwolff.treesitter.excavationsite.api.ExtractionContext as LibraryExtractionContext + +/** + * Extract stage: Extracts identifiers, comments, and strings from source code using TreeSitterLibrary. + * + * This stage delegates to TreeSitterExtraction for AST-based text extraction. + */ +class ExtractStage(private val language: Language) { + fun extract(sourceCode: String): List { + if (sourceCode.isBlank()) { + return emptyList() + } + + val result = TreeSitterExtraction.extract(sourceCode, language) + + return result.extractedTexts.map { extracted -> + ExtractedText( + text = extracted.text, + context = + when (extracted.context) { + LibraryExtractionContext.IDENTIFIER -> ExtractionContext.IDENTIFIER + LibraryExtractionContext.COMMENT -> ExtractionContext.COMMENT + LibraryExtractionContext.STRING -> ExtractionContext.STRING + } + ) + } + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/FilterStage.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/FilterStage.kt new file mode 100644 index 0000000000..5cd2378f58 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/FilterStage.kt @@ -0,0 +1,27 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.StopWordFilter +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.WeightedText +import java.nio.file.Path + +/** + * Filter stage: Removes stop words from weighted text list. + * + * This stage filters out: + * - English stop words (the, a, an, etc.) + * - Language keywords (class, fun, val, etc.) + * - Technical stop words (test, util, manager, etc.) + * - Custom stop words from .dlcignore + * - Framework-specific keywords (scoped to files under framework directories) + * + * N-grams are filtered if ANY component word is a stop word. + */ +class FilterStage(private val stopWordFilter: StopWordFilter) { + fun filter(weightedTexts: List): List = weightedTexts.filter { weighted -> + !stopWordFilter.isExcluded(weighted.text) + } + + fun filter(weightedTexts: List, filePath: Path): List = weightedTexts.filter { weighted -> + !stopWordFilter.isExcluded(weighted.text, filePath) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/NgramsStage.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/NgramsStage.kt new file mode 100644 index 0000000000..875ccdecdc --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/NgramsStage.kt @@ -0,0 +1,125 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.ExtractionContext +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.WeightedText + +/** + * N-grams stage: Generates n-grams from identifier words while preserving individual words. + * + * For identifiers with ngrams > 1, this stage generates both individual words and compound phrases. + * For comments/strings, only individual words are kept (no n-grams generated). + * + * After generation, applies Statistical Substring Reduction (SSR) to remove redundant n-grams. + * SSR rule: If an n-gram (n≥2) is a substring of a longer n-gram and has equal or lower frequency, + * the shorter one is removed. + * + * Example with ngrams=2: + * Input: "userProfile" (split to ["user", "profile"]) + * Output: ["user", "profile", "user profile"] + */ +class NgramsStage(private val ngrams: Int = 1, private val enableSsr: Boolean = true) { + fun generateNgrams(splitResults: List): List { + if (ngrams <= 1) { + return splitResults.flatMap { it.words } + } + + val result = mutableListOf() + + splitResults.forEach { splitResult -> + // Add all individual words + result.addAll(splitResult.words) + + // Generate n-grams only for identifiers + if (splitResult.context == ExtractionContext.IDENTIFIER && splitResult.sourceWords.size >= 2) { + for (n in 2..ngrams.coerceAtMost(splitResult.sourceWords.size)) { + splitResult.sourceWords.windowed(n, partialWindows = false).forEach { window -> + val ngramText = window.joinToString(" ") + result.add(WeightedText(ngramText, splitResult.weight, splitResult.context)) + } + } + } + } + + return if (enableSsr) applyStatisticalSubstringReduction(result) else result + } + + /** + * Apply Statistical Substring Reduction to filter redundant n-grams. + * + * Algorithm: + * 1. Group weighted texts by text content, sum weights to get frequencies + * 2. Pre-split all n-grams into word lists (avoid repeated splitting) + * 3. Group n-grams by word count for efficient comparison + * 4. For each shorter n-gram, only check longer n-grams (reduces comparisons) + * 5. Use word-based subsequence check and early termination + * 6. If substring frequency ≤ containing n-gram frequency, mark for removal + * 7. Return filtered list + */ + private fun applyStatisticalSubstringReduction(weightedTexts: List): List { + // Step 1: Calculate frequencies (sum of weights for each unique text) + val frequencies = weightedTexts.groupBy { it.text }.mapValues { (_, texts) -> texts.sumOf { it.weight } } + + // Separate unigrams from n-grams + val ngramFrequencies = frequencies.filter { (text, _) -> text.contains(" ") } + + if (ngramFrequencies.size <= 1) { + return weightedTexts + } + + // Step 2: Pre-split all n-grams into word lists (avoid repeated splitting) + val ngramWords: Map> = ngramFrequencies.keys.associateWith { it.split(" ") } + + // Step 3: Group n-grams by word count for efficient comparison + val ngramsByWordCount: Map>>> = + ngramWords.entries.groupBy( + keySelector = { it.value.size }, + valueTransform = { it.key to it.value } + ) + val wordCounts = ngramsByWordCount.keys.sorted() + + // Step 4: Find n-grams to remove (only compare shorter to longer) + val textsToRemove = mutableSetOf() + + for (shorterWordCount in wordCounts.dropLast(1)) { + val shorterNgrams = ngramsByWordCount[shorterWordCount] ?: continue + + for ((shorterNgram, shorterWords) in shorterNgrams) { + if (shorterNgram in textsToRemove) continue + + val shorterFreq = ngramFrequencies[shorterNgram] ?: continue + + // Step 5: Only check n-grams with more words, use early termination + val found = + wordCounts + .asSequence() + .filter { it > shorterWordCount } + .any { longerWordCount -> + val longerNgrams = ngramsByWordCount[longerWordCount] ?: emptyList() + longerNgrams.any { (longerNgram, longerWords) -> + val longerFreq = ngramFrequencies[longerNgram] ?: 0 + // Word-based containment check + containsWordSubsequence(longerWords, shorterWords) && shorterFreq <= longerFreq + } + } + + if (found) { + textsToRemove.add(shorterNgram) + } + } + } + + // Step 7: Filter out removed n-grams + return weightedTexts.filter { it.text !in textsToRemove } + } + + /** + * Checks if the longer word list contains the shorter word list as a contiguous subsequence. + * Uses joinToString for the actual comparison to avoid character-by-character iteration. + */ + private fun containsWordSubsequence(longer: List, shorter: List): Boolean { + if (shorter.size >= longer.size) return false + val shortStr = shorter.joinToString(" ") + val longStr = longer.joinToString(" ") + return longStr.contains(shortStr) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/SplitStage.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/SplitStage.kt new file mode 100644 index 0000000000..0a586d88a6 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/SplitStage.kt @@ -0,0 +1,60 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.ExtractionContext +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.WeightedText +import net.pearx.kasechange.splitToWords + +/** + * Split stage: Splits weighted text into individual words while preserving weights. + * + * For identifiers: Splits camelCase/PascalCase/snake_case into component words. + * For comments/strings: Extracts words using regex pattern. + * + * Returns pairs of (individual words, original source text) to enable n-gram generation later. + */ +class SplitStage(private val ngrams: Int = 1) { + data class SplitResult( + val words: List, + val sourceWords: List, // Words from original identifier (for n-gram generation) + val weight: Int, + val context: ExtractionContext + ) + + fun split(weightedTexts: List): List = weightedTexts.map { weighted -> + val words = + when (weighted.context) { + ExtractionContext.IDENTIFIER -> splitIdentifier(weighted.text) + ExtractionContext.COMMENT, ExtractionContext.STRING -> extractWords(weighted.text) + } + + val weightedWords = words.map { WeightedText(it, weighted.weight, weighted.context) } + + SplitResult( + words = weightedWords, + sourceWords = words, + weight = weighted.weight, + context = weighted.context + ) + } + + private fun splitIdentifier(identifier: String): List = identifier + .splitToWords() + .map { sanitize(it) } + .filter { isSignificantWord(it) } + + private fun extractWords(text: String): List = WORD_PATTERN + .findAll(text) + .flatMap { it.value.splitToWords() } + .map { sanitize(it) } + .filter { isSignificantWord(it) } + .toList() + + private fun sanitize(word: String): String = word.filter { it.isLetterOrDigit() }.lowercase() + + private fun isSignificantWord(word: String): Boolean = word.length >= MIN_WORD_LENGTH + + companion object { + private const val MIN_WORD_LENGTH = 2 + private val WORD_PATTERN = Regex("""\b[a-zA-Z]{3,}\b""") + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/WeightStage.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/WeightStage.kt new file mode 100644 index 0000000000..431e10d5dd --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/WeightStage.kt @@ -0,0 +1,26 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.ExtractionWeights +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.ExtractedText +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.ExtractionContext +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.WeightedText + +/** + * Weight stage: Applies weights to extracted text based on its context. + * + * Different contexts receive different weights: + * - Identifiers: highest weight (default 3) - these are domain terms + * - Comments: medium weight (default 2) - explanatory terms + * - Strings: lowest weight (default 1) - UI messages and constants + */ +class WeightStage(private val weights: ExtractionWeights = ExtractionWeights()) { + fun weight(extractedTexts: List): List = extractedTexts.map { extracted -> + val weight = + when (extracted.context) { + ExtractionContext.IDENTIFIER -> weights.identifierWeight + ExtractionContext.COMMENT -> weights.commentWeight + ExtractionContext.STRING -> weights.stringWeight + } + WeightedText(extracted.text, weight, extracted.context) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/stopwords/DlcIgnoreParser.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/stopwords/DlcIgnoreParser.kt new file mode 100644 index 0000000000..bc30150277 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/stopwords/DlcIgnoreParser.kt @@ -0,0 +1,24 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.stopwords + +import java.io.File +import java.nio.file.Path + +class DlcIgnoreParser { + fun loadCustomStopWords(directoryPath: Path): Set { + val dlcIgnoreFile = directoryPath.resolve(".dlcignore").toFile() + + if (!dlcIgnoreFile.exists() || !dlcIgnoreFile.isFile) { + return emptySet() + } + + return parseDlcIgnoreFile(dlcIgnoreFile) + } + + private fun parseDlcIgnoreFile(file: File): Set = file.bufferedReader().useLines { lines -> + lines + .map { it.trim() } + .filterNot { it.isEmpty() || it.startsWith("#") } + .map { it.lowercase() } + .toSet() + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporter.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporter.kt new file mode 100644 index 0000000000..e467d9fec8 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporter.kt @@ -0,0 +1,27 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress + +import java.io.Closeable + +/** + * Reports progress for long-running operations. + * Implementations must be thread-safe for concurrent updates from coroutines. + */ +interface ProgressReporter : Closeable { + /** + * Start a new progress phase. + * @param phaseName Display name for this phase (e.g., "Scanning files") + * @param total Total number of items to process, or null for indeterminate progress + */ + fun startPhase(phaseName: String, total: Long?) + + /** + * Report progress on current phase. Thread-safe for concurrent calls. + * @param count Number of items completed (default 1) + */ + fun advance(count: Long = 1) + + /** + * Complete the current phase. + */ + fun completePhase() +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporterFactory.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporterFactory.kt new file mode 100644 index 0000000000..0230bdc468 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporterFactory.kt @@ -0,0 +1,5 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress + +object ProgressReporterFactory { + fun create(quiet: Boolean): ProgressReporter = if (quiet) SilentProgressReporter else ProgressTrackerReporter() +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressTrackerReporter.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressTrackerReporter.kt new file mode 100644 index 0000000000..52da6525e4 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressTrackerReporter.kt @@ -0,0 +1,43 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress + +import de.maibornwolff.codecharta.progresstracker.ParsingUnit +import de.maibornwolff.codecharta.progresstracker.ProgressTracker +import java.util.concurrent.atomic.AtomicLong + +/** + * Progress reporter backed by codecharta's shared [ProgressTracker], replacing the mordant-based + * implementation. It renders a determinate progress bar to stderr; indeterminate phases (unknown + * total) report no bar, matching [ProgressTracker]'s contract. + */ +class ProgressTrackerReporter(private val parsingUnit: ParsingUnit = ParsingUnit.Files) : ProgressReporter { + private var progressTracker = ProgressTracker() + private var total: Long = 0 + private val parsed = AtomicLong(0) + + override fun startPhase(phaseName: String, total: Long?) { + this.total = total ?: 0 + this.parsed.set(0) + // Reset the tracker so its ETA baseline restarts with each phase. + this.progressTracker = ProgressTracker() + System.err.println(phaseName) + if (this.total > 0) { + progressTracker.updateProgress(this.total, 0, parsingUnit.name) + } + } + + override fun advance(count: Long) { + val done = parsed.addAndGet(count) + if (total > 0) { + progressTracker.updateProgress(total, done, parsingUnit.name) + } + } + + override fun completePhase() { + if (total > 0) { + progressTracker.updateProgress(total, total, parsingUnit.name) + System.err.println() + } + } + + override fun close() = Unit +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/SilentProgressReporter.kt b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/SilentProgressReporter.kt new file mode 100644 index 0000000000..99bb0d7a46 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/SilentProgressReporter.kt @@ -0,0 +1,14 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress + +/** + * No-op implementation for --quiet mode or testing. + */ +object SilentProgressReporter : ProgressReporter { + override fun startPhase(phaseName: String, total: Long?) = Unit + + override fun advance(count: Long) = Unit + + override fun completePhase() = Unit + + override fun close() = Unit +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/abl-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/abl-keywords.txt new file mode 100644 index 0000000000..472c9075a9 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/abl-keywords.txt @@ -0,0 +1,83 @@ +# ABL (OpenEdge/Progress) Framework-Specific Terms +# Only filtering Progress framework classes and built-in system objects +# Language syntax keywords are not extracted by tree-sitter anyway + +# Progress.Lang namespace classes +Progress +Lang +Object +AppError +Error +SoapFaultError +ProError +StopError +SysError +LockConflict +QueryOffEnd + +# Progress.Json namespace +Json +ObjectModel +JsonObject +JsonArray +JsonDataType +JsonConstruct + +# Progress.IO namespace +FileInputStream +FileOutputStream +InputStream +OutputStream +BinaryReader +BinaryWriter +MemoryInputStream +MemoryOutputStream + +# Progress.Reflect namespace +Reflect +Property +Method +Parameter +Constructor +DataMember +ClassInfo + +# Built-in System Handles +SESSION +COMPILER +DEBUGGER +CLIPBOARD +COLOR-TABLE +FILE-INFO +FONT-TABLE +LAST-EVENT +LOG-MANAGER +PROFILER +RCODE-INFO +SECURITY-POLICY +SELF +SOURCE-PROCEDURE +TARGET-PROCEDURE +THIS-OBJECT +THIS-PROCEDURE +WEB-CONTEXT + +# OpenClient/AppServer +AppServer +WebService +Server +ServerSocket +Socket + +# ProDataSet framework terms +ProDataSet +ProBindingSource +DataSource +DataRelation + +# Common OpenEdge annotations (4GL attributes) +Serializable +NonSerialized +DefaultValue +XMLNodeName +XMLNodeType diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/angular-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/angular-keywords.txt new file mode 100644 index 0000000000..ca8c2918a1 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/angular-keywords.txt @@ -0,0 +1,93 @@ +# Angular Framework Keywords +# Common Angular-specific terms to filter from word clouds +# One keyword per line, case-insensitive + +# Core Angular concepts +component +directive +pipe +service +module +provider +injector +decorator + +# Component-related +selector +template +input +output +viewchild +viewchildren +contentchild +contentchildren +hostbinding +hostlistener + +# State management (NgRx) +state +store +action +actions +reducer +reducers +effect +effects +selector +selectors +dispatch +createaction +createreducer +createeffect +createselector +createfeature + +# Lifecycle hooks +oninit +ondestroy +onchanges +docheck +afterviewinit +afterviewchecked +aftercontentinit +aftercontentchecked + +# Routing +route +router +routes +activated +navigation +routerlink +routeroutlet + +# Dependency injection +injectable +inject +injection +token + +# Forms +formgroup +formcontrol +formarray +formbuilder +validators +validation + +# HTTP +httpclient +observable +subscription +subscribe + +# Testing +testbed +fixture +componentfixture +debugelement + +# UI elements +button +mat +ng diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/aspnet-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/aspnet-keywords.txt new file mode 100644 index 0000000000..d88a3ba3d0 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/aspnet-keywords.txt @@ -0,0 +1,170 @@ +# ASP.NET Framework Keywords +# Common ASP.NET terms to filter for cleaner domain-focused word clouds +# One keyword per line, case-sensitive +# Comments start with # + +# MVC Pattern +Controller +ControllerBase +Model +View +ViewModel +ViewData +ViewBag +TempData +ActionResult +IActionResult +ViewResult +JsonResult +ContentResult +RedirectResult +PartialViewResult +FileResult +EmptyResult + +# HTTP Verbs and Attributes +HttpGet +HttpPost +HttpPut +HttpDelete +HttpPatch +HttpHead +HttpOptions +Route +FromBody +FromQuery +FromRoute +FromForm +FromHeader +FromServices +Bind +Authorize +AllowAnonymous +ValidateAntiForgeryToken + +# Middleware and Pipeline +Middleware +RequestDelegate +HttpContext +HttpRequest +HttpResponse +StatusCode +UseMiddleware +UseRouting +UseEndpoints +UseAuthorization +UseAuthentication +MapControllers +MapRazorPages +MapGet +MapPost +MapPut +MapDelete + +# Web API +ApiController +Produces +Consumes +ProducesResponseType +ResponseType +BadRequest +NotFound +Ok +Created +NoContent +Accepted +Conflict +UnprocessableEntity + +# Razor Pages +PageModel +RazorPage +OnGet +OnPost +OnPut +OnDelete +Handler +BindProperty +PageContext + +# Filters +ActionFilter +ResultFilter +ExceptionFilter +ResourceFilter +AuthorizationFilter +OnActionExecuting +OnActionExecuted +OnResultExecuting +OnResultExecuted +OnException + +# Dependency Injection +AddScoped +AddTransient +AddSingleton +IServiceCollection +IServiceProvider +ServiceLifetime + +# Configuration +IConfiguration +IOptions +ConfigureServices +Configure +Startup +WebApplication +WebApplicationBuilder + +# Blazor +ComponentBase +Parameter +EventCallback +StateHasChanged +OnInitialized +OnParametersSet +OnAfterRender +CascadingParameter +RenderFragment +MarkupString + +# SignalR +Hub +HubConnection +HubConnectionBuilder +OnConnectedAsync +OnDisconnectedAsync +SendAsync +InvokeAsync + +# Authentication/Authorization +Identity +IdentityUser +IdentityRole +SignInManager +UserManager +RoleManager +Claims +ClaimsPrincipal +ClaimsIdentity +AuthenticationScheme +JwtBearer +Cookie + +# Session and State +Session +SessionState +Cache +MemoryCache +DistributedCache +ISession + +# Hosting +IWebHost +IWebHostBuilder +WebHost +IHostBuilder +IHost +HostBuilder +IApplicationBuilder +IEndpointRouteBuilder diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/bash-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/bash-keywords.txt new file mode 100644 index 0000000000..de9745bbb8 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/bash-keywords.txt @@ -0,0 +1,96 @@ +# Bash Keywords +# One keyword per line, case-sensitive +# Comments start with # + +# Reserved words +case +do +done +elif +else +esac +fi +for +function +if +in +select +then +until +while +time +coproc + +# Builtin commands +alias +bg +bind +break +builtin +caller +cd +command +compgen +complete +compopt +continue +declare +dirs +disown +echo +enable +eval +exec +exit +export +false +fc +fg +getopts +hash +help +history +jobs +kill +let +local +logout +mapfile +popd +printf +pushd +pwd +read +readarray +readonly +return +set +shift +shopt +source +suspend +test +times +trap +true +type +typeset +ulimit +umask +unalias +unset +wait + +# Common shell variables +BASH +BASH_ENV +BASH_VERSION +CDPATH +HOME +IFS +OLDPWD +PATH +PS1 +PS2 +PWD +SHELL diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/c-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/c-keywords.txt new file mode 100644 index 0000000000..cd00e40060 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/c-keywords.txt @@ -0,0 +1,70 @@ +# C Keywords +# One keyword per line, case-sensitive +# Comments start with # + +# Reserved words +auto +break +case +char +const +continue +default +do +double +else +enum +extern +float +for +goto +if +inline +int +long +register +restrict +return +short +signed +sizeof +static +struct +switch +typedef +union +unsigned +void +volatile +while +_Alignas +_Alignof +_Atomic +_Bool +_Complex +_Generic +_Imaginary +_Noreturn +_Static_assert +_Thread_local + +# Standard types +size_t +ptrdiff_t +NULL +FILE +EOF + +# Standard functions +printf +scanf +malloc +free +calloc +realloc +strlen +strcpy +strcat +strcmp +memcpy +memset diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/cpp-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/cpp-keywords.txt new file mode 100644 index 0000000000..23153093d0 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/cpp-keywords.txt @@ -0,0 +1,209 @@ +# C++ Keywords and Common Types +# One keyword per line, case-sensitive +# Comments start with # + +# C++ specific keywords +alignas +alignof +and +and_eq +asm +bitand +bitor +catch +class +compl +concept +consteval +constexpr +constinit +const_cast +co_await +co_return +co_yield +decltype +delete +dynamic_cast +explicit +export +false +friend +mutable +namespace +new +noexcept +not +not_eq +nullptr +operator +or +or_eq +private +protected +public +reinterpret_cast +requires +static_assert +static_cast +template +this +thread_local +throw +true +try +typeid +typename +using +virtual +wchar_t +xor +xor_eq + +# C keywords (C++ is a superset of C) +auto +break +case +char +const +continue +default +do +double +else +enum +extern +float +for +goto +if +inline +int +long +register +restrict +return +short +signed +sizeof +static +struct +switch +typedef +union +unsigned +void +volatile +while +_Alignas +_Alignof +_Atomic +_Bool +_Complex +_Generic +_Imaginary +_Noreturn +_Static_assert +_Thread_local + +# Primitive types +bool +char8_t +char16_t +char32_t +int8_t +int16_t +int32_t +int64_t +uint8_t +uint16_t +uint32_t +uint64_t +size_t +ptrdiff_t +nullptr_t + +# STL common container types +vector +string +map +set +list +deque +array +unordered_map +unordered_set +multimap +multiset +unordered_multimap +unordered_multiset +stack +queue +priority_queue + +# STL common utility types +pair +tuple +optional +variant +any +function +shared_ptr +unique_ptr +weak_ptr +make_shared +make_unique +move +forward +swap +exchange + +# STL common algorithm/iterator types +iterator +const_iterator +reverse_iterator +const_reverse_iterator +begin +end +rbegin +rend +cbegin +cend +crbegin +crend + +# Common STL namespaces and utility +std +cout +cin +cerr +clog +endl +flush + +# Common STL classes +iostream +fstream +ifstream +ofstream +stringstream +istringstream +ostringstream +exception +runtime_error +logic_error +invalid_argument +out_of_range +overflow_error +underflow_error +bad_alloc +bad_cast +bad_typeid +bad_exception + +# Common C library types (often used in C++) +FILE +size_t +time_t +clock_t +NULL +EOF diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/csharp-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/csharp-keywords.txt new file mode 100644 index 0000000000..3a75716dbc --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/csharp-keywords.txt @@ -0,0 +1,242 @@ +# C# Keywords (C# 1.0 through C# 11.0) +# One keyword per line, case-sensitive +# Comments start with # + +# Core Keywords (C# 1.0-2.0) +abstract +as +base +bool +break +byte +case +catch +char +checked +class +const +continue +decimal +default +delegate +do +double +else +enum +event +explicit +extern +false +finally +fixed +float +for +foreach +goto +if +implicit +in +int +interface +internal +is +lock +long +namespace +new +null +object +operator +out +override +params +private +protected +public +readonly +ref +return +sbyte +sealed +short +sizeof +stackalloc +static +string +struct +switch +this +throw +true +try +typeof +uint +ulong +unchecked +unsafe +ushort +using +virtual +void +volatile +while + +# C# 2.0 Keywords +where +yield + +# C# 3.0 Keywords +var +from +select +let +orderby +ascending +descending +group +by +join +on +equals +into + +# C# 5.0 Keywords +async +await + +# C# 7.0 Keywords +when + +# C# 9.0 Keywords +record +init +and +or +not + +# C# 10.0 Keywords +global +file + +# C# 11.0 Keywords +required +scoped + +# Contextual Keywords +add +alias +get +partial +remove +set +value +dynamic + +# Common .NET Primitive Types +Boolean +Byte +SByte +Char +Decimal +Double +Single +Int16 +Int32 +Int64 +UInt16 +UInt32 +UInt64 +IntPtr +UIntPtr +String +Object +Void + +# Common .NET Collection Types +Array +List +Dictionary +HashSet +Queue +Stack +LinkedList +SortedList +SortedSet +SortedDictionary +Collection +ReadOnlyCollection +IEnumerable +ICollection +IList +IDictionary +ISet +IReadOnlyCollection +IReadOnlyList +IReadOnlyDictionary + +# Common .NET Types +Exception +Type +Attribute +Nullable +Task +ValueTask +Action +Func +Predicate +EventArgs +EventHandler +Delegate +Enum +DateTime +DateTimeOffset +TimeSpan +Guid +StringBuilder +StringComparer +Tuple +ValueTuple +Lazy +WeakReference +CancellationToken +CancellationTokenSource + +# Common .NET Interfaces +IDisposable +IEquatable +IComparable +IComparer +IConvertible +IFormattable +ICloneable +IAsyncDisposable +IAsyncEnumerable +IAsyncEnumerator +IObservable +IObserver + +# Common LINQ Types +IQueryable +IGrouping +Enumerable +Queryable + +# Common Threading Types +Thread +ThreadPool +Mutex +Semaphore +Monitor +ReaderWriterLock +AutoResetEvent +ManualResetEvent + +# Common System Types +Console +Convert +Math +Random +Environment +GC +Buffer +BitConverter diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/entityframework-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/entityframework-keywords.txt new file mode 100644 index 0000000000..8e5dbe65ef --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/entityframework-keywords.txt @@ -0,0 +1,208 @@ +# Entity Framework Keywords +# Common Entity Framework terms to filter for cleaner domain-focused word clouds +# One keyword per line, case-sensitive +# Comments start with # + +# Core EF Types +DbContext +DbSet +DbContextOptions +DbContextOptionsBuilder +ModelBuilder +EntityEntry +EntityState +ChangeTracker +DatabaseFacade + +# Entity Configuration +Entity +EntityType +EntityTypeBuilder +HasKey +HasIndex +HasOne +HasMany +WithOne +WithMany +OnDelete +IsRequired +HasMaxLength +HasColumnName +HasColumnType +HasDefaultValue +HasPrecision +HasConstraintName +HasForeignKey +HasPrincipalKey + +# Querying +IQueryable +AsQueryable +AsNoTracking +AsTracking +Include +ThenInclude +Where +Select +SelectMany +OrderBy +OrderByDescending +ThenBy +ThenByDescending +GroupBy +Join +FirstOrDefault +SingleOrDefault +ToList +ToArray +Any +All +Count +Sum +Average +Min +Max +Skip +Take +Contains + +# Tracking and Changes +Attach +Add +AddRange +Update +UpdateRange +Remove +RemoveRange +Entry +Modified +Added +Deleted +Unchanged +Detached + +# Migrations +Migration +MigrationBuilder +CreateTable +DropTable +AddColumn +DropColumn +AlterColumn +AddPrimaryKey +DropPrimaryKey +AddForeignKey +DropForeignKey +CreateIndex +DropIndex +RenameTable +RenameColumn +RenameIndex +Up +Down +MigrationHistory +Database +EnsureCreated +EnsureDeleted +Migrate +PendingMigrations +AppliedMigrations + +# Conventions +Convention +Conventions +IConvention +ModelConfigurationBuilder + +# Relationships +Navigation +NavigationProperty +ReferenceNavigation +CollectionNavigation +DeleteBehavior +Cascade +Restrict +SetNull +NoAction +ClientSetNull + +# Fluent API +Fluent +FluentAPI +HasData +HasQueryFilter +HasDiscriminator +ToTable +ToView +UseTpcMappingStrategy +UseTphMappingStrategy +UseTptMappingStrategy + +# Inheritance +TPC +TPH +TPT +BaseType +DerivedType +Discriminator + +# Lazy Loading +LazyLoading +Lazy +Proxy +Proxies +ProxyCreation + +# Performance +Compiled +CompiledQuery +AsEnumerable +SplitQuery +AsSplitQuery +AsSingleQuery +EnableSensitiveDataLogging +EnableDetailedErrors + +# Transactions +Transaction +BeginTransaction +CommitTransaction +RollbackTransaction +SaveChanges +SaveChangesAsync +UseTransaction + +# Connection and Configuration +ConnectionString +Database +DatabaseProvider +UseSqlServer +UseSqlite +UseNpgsql +UseMySql +UseInMemoryDatabase +UseLoggerFactory +LogTo + +# Value Conversion +ValueConverter +ValueComparer +HasConversion + +# Shadow Properties +Shadow +ShadowProperty +Property + +# Owned Types +Owned +OwnsOne +OwnsMany + +# Global Filters +QueryFilter +IgnoreQueryFilters + +# Change Detection +DetectChanges +AutoDetectChangesEnabled diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/go-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/go-keywords.txt new file mode 100644 index 0000000000..e3a28731c7 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/go-keywords.txt @@ -0,0 +1,75 @@ +# Go Keywords and Built-in Types +# One keyword per line, case-sensitive +# Comments start with # + +# Reserved words +break +case +chan +const +continue +default +defer +else +fallthrough +for +func +go +goto +if +import +interface +map +package +range +return +select +struct +switch +type +var + +# Built-in types +bool +byte +complex64 +complex128 +error +float32 +float64 +int +int8 +int16 +int32 +int64 +rune +string +uint +uint8 +uint16 +uint32 +uint64 +uintptr + +# Built-in functions +append +cap +close +complex +copy +delete +imag +len +make +new +panic +print +println +real +recover + +# Constants +true +false +nil +iota diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/java-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/java-keywords.txt new file mode 100644 index 0000000000..0ef092d2e4 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/java-keywords.txt @@ -0,0 +1,124 @@ +# Java Keywords and Common Types +# One keyword per line, case-sensitive +# Comments start with # + +# Keywords +abstract +assert +boolean +break +byte +case +catch +char +class +const +continue +default +do +double +else +enum +extends +final +finally +float +for +goto +if +implements +import +instanceof +int +interface +long +native +new +package +private +protected +public +return +short +static +strictfp +super +switch +synchronized +this +throw +throws +transient +try +void +volatile +while + +# Contextual keywords (Java 10+) +var +yield +record +sealed +permits +non-sealed + +# Literals +true +false +null + +# Primitive types (duplicates removed) + +# Common wrapper classes +Boolean +Byte +Character +Short +Integer +Long +Float +Double +String +Object +Class +Number +Void + +# Common built-in classes +System +Math +Thread +Runtime +Exception +Error +Throwable +StackTraceElement + +# Common collection types +List +Set +Map +Collection +ArrayList +HashSet +HashMap +LinkedList +TreeSet +TreeMap +Vector +Stack +Queue +Deque +Iterator +Iterable +Comparable +Comparator + +# Common utility types +Optional +Stream +Arrays +Collections +Objects +StringBuilder +StringBuffer diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/javascript-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/javascript-keywords.txt new file mode 100644 index 0000000000..5bd53d0345 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/javascript-keywords.txt @@ -0,0 +1,53 @@ +# JavaScript Reserved Words +# One keyword per line, case-sensitive +# Comments start with # +# Note: Does NOT include TypeScript-only keywords (interface, type, namespace, enum, etc.) + +# JavaScript reserved words +break +case +catch +class +const +continue +debugger +default +delete +do +else +export +extends +finally +for +function +if +import +in +instanceof +new +return +super +switch +this +throw +try +typeof +var +void +while +with +yield + +# JavaScript strict mode reserved words (also valid in JS) +let +static + +# JavaScript literals +true +false +null +undefined + +# ES2017+ keywords +async +await diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/kotlin-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/kotlin-keywords.txt new file mode 100644 index 0000000000..efa53c0c56 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/kotlin-keywords.txt @@ -0,0 +1,87 @@ +# Kotlin Keywords +# One keyword per line, case-sensitive +# Comments start with # + +# Hard keywords - always reserved +as +break +class +continue +do +else +false +for +fun +if +in +interface +is +null +object +package +return +super +this +throw +true +try +typealias +typeof +val +var +when +while + +# Soft keywords - contextually reserved +by +catch +constructor +delegate +dynamic +field +file +finally +get +import +init +param +property +receiver +set +setparam +value +where + +# Modifier keywords - reserved in modifier lists +abstract +actual +annotation +companion +const +crossinline +data +enum +expect +external +final +infix +inline +inner +internal +lateinit +noinline +open +operator +out +override +private +protected +public +reified +sealed +suspend +tailrec +vararg + +# Special identifiers +it diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/objc-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/objc-keywords.txt new file mode 100644 index 0000000000..432fbc0bb5 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/objc-keywords.txt @@ -0,0 +1,111 @@ +# Objective-C Keywords and Common Types +# One keyword per line, case-sensitive +# Comments start with # + +# Objective-C specific keywords +@interface +@implementation +@end +@protocol +@optional +@required +@property +@synthesize +@dynamic +@class +@selector +@encode +@defs +@synchronized +@try +@catch +@finally +@throw +@autoreleasepool +@compatibility_alias +self +super +nil +Nil +YES +NO +id +Class +SEL +IMP +BOOL + +# C keywords (inherited) +auto +break +case +char +const +continue +default +do +double +else +enum +extern +float +for +goto +if +int +long +register +return +short +signed +sizeof +static +struct +switch +typedef +union +unsigned +void +volatile +while + +# Common Foundation types +NSObject +NSString +NSArray +NSDictionary +NSNumber +NSMutableArray +NSMutableDictionary +NSMutableString +NSData +NSDate +NSURL +NSError +NSNotification +NSBundle +NSSet +NSMutableSet +NSValue +NSDecimalNumber +NSIndexPath +NSRange +NSNull +NSException +NSPredicate +NSAttributedString +NSMutableAttributedString + +# Common macros and qualifiers +NS_ASSUME_NONNULL_BEGIN +NS_ASSUME_NONNULL_END +nullable +nonnull +__block +__weak +__strong +__autoreleasing +NS_ENUM +NS_OPTIONS +IBOutlet +IBAction diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/php-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/php-keywords.txt new file mode 100644 index 0000000000..5b3c34ea06 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/php-keywords.txt @@ -0,0 +1,105 @@ +# PHP Keywords and Types +# One keyword per line, case-sensitive +# Comments start with # + +# Reserved words +abstract +and +array +as +break +callable +case +catch +class +clone +const +continue +declare +default +die +do +echo +else +elseif +empty +enddeclare +endfor +endforeach +endif +endswitch +endwhile +eval +exit +extends +final +finally +fn +for +foreach +function +global +goto +if +implements +include +include_once +instanceof +insteadof +interface +isset +list +match +namespace +new +or +print +private +protected +public +readonly +require +require_once +return +static +switch +throw +trait +try +unset +use +var +while +xor +yield +yield_from + +# Types +bool +boolean +int +integer +float +double +string +array +object +null +void +mixed +never +true +false +iterable +self +parent + +# Magic constants +__CLASS__ +__DIR__ +__FILE__ +__FUNCTION__ +__LINE__ +__METHOD__ +__NAMESPACE__ +__TRAIT__ diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/python-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/python-keywords.txt new file mode 100644 index 0000000000..efcb6e420a --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/python-keywords.txt @@ -0,0 +1,144 @@ +# Python Keywords and Built-in Types +# One keyword per line, case-sensitive +# Comments start with # + +# Keywords +False +None +True +and +as +assert +async +await +break +class +continue +def +del +elif +else +except +finally +for +from +global +if +import +in +is +lambda +nonlocal +not +or +pass +raise +return +try +while +with +yield + +# Built-in types +bool +int +float +complex +str +bytes +bytearray +list +tuple +range +dict +set +frozenset +type +object + +# Common built-in functions +abs +all +any +ascii +bin +chr +compile +delattr +dir +divmod +enumerate +eval +exec +filter +format +getattr +globals +hasattr +hash +hex +id +input +isinstance +issubclass +iter +len +locals +map +max +min +next +oct +open +ord +pow +print +repr +reversed +round +setattr +slice +sorted +staticmethod +sum +super +vars +zip + +# Common built-in exceptions +Exception +BaseException +SystemExit +KeyboardInterrupt +GeneratorExit +StopIteration +StopAsyncIteration +ArithmeticError +FloatingPointError +OverflowError +ZeroDivisionError +AssertionError +AttributeError +BufferError +EOFError +ImportError +ModuleNotFoundError +LookupError +IndexError +KeyError +MemoryError +NameError +UnboundLocalError +OSError +FileNotFoundError +RuntimeError +NotImplementedError +RecursionError +SyntaxError +IndentationError +TabError +SystemError +TypeError +ValueError +UnicodeError +Warning diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/react-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/react-keywords.txt new file mode 100644 index 0000000000..bd77c060b4 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/react-keywords.txt @@ -0,0 +1,107 @@ +# React Framework Keywords +# Common React-specific terms to filter from word clouds +# One keyword per line, case-insensitive + +# Core React concepts +component +props +state +ref +element +children +key + +# Class component lifecycle +componentdidmount +componentdidupdate +componentwillunmount +shouldcomponentupdate +getderivedstatefromprops +getsnapshotbeforeupdate +componentdidcatch + +# Built-in hooks +usestate +useeffect +usecontext +usereducer +usecallback +usememo +useref +useimperativehandle +uselayouteffect +usedebugvalue +useid +usetransition +usedeferredvalue +usesyncexternalstore +useinsertioneffect + +# State management (Redux) +redux +store +action +dispatch +reducer +selector +middleware +thunk +saga +slice +createslice +configurestore + +# Context API +context +provider +consumer +createcontext + +# React ecosystem +jsx +tsx +fragment +portal +createportal +suspense +lazy +memo +forwardref +createref +cloneelement +createelement +isvalidelement +children + +# React Router +router +route +routes +link +navigate +navigation +navlink +outlet +params +searchparams +location +history + +# Error boundaries +errorboundary +componentdidcatch +getderivedstatefromerror + +# React testing +render +renderhook +screen +fireEvent +waitfor +userEvent +rerender + +# Common patterns +hoc +higherordercomponent +renderprops diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/ruby-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/ruby-keywords.txt new file mode 100644 index 0000000000..2d056c3769 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/ruby-keywords.txt @@ -0,0 +1,84 @@ +# Ruby Keywords +# One keyword per line, case-sensitive +# Comments start with # + +# Reserved words +__ENCODING__ +__LINE__ +__FILE__ +BEGIN +END +alias +and +begin +break +case +class +def +defined? +do +else +elsif +end +ensure +false +for +if +in +module +next +nil +not +or +redo +rescue +retry +return +self +super +then +true +undef +unless +until +when +while +yield + +# Built-in classes +Array +Hash +String +Integer +Float +Symbol +Range +Regexp +Proc +Method +Object +Class +Module +Exception +Enumerable + +# Common methods +attr_reader +attr_writer +attr_accessor +initialize +puts +print +gets +require +require_relative +include +extend +raise +new +each +map +select +reject +reduce +collect diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/rust-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/rust-keywords.txt new file mode 100644 index 0000000000..0cd073dd1e --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/rust-keywords.txt @@ -0,0 +1,116 @@ +# Rust Keywords and Common Types +# One keyword per line, case-sensitive +# Comments start with # + +# Strict keywords +as +async +await +break +const +continue +crate +dyn +else +enum +extern +false +fn +for +if +impl +in +let +loop +match +mod +move +mut +pub +ref +return +self +Self +static +struct +super +trait +true +type +union +unsafe +use +where +while + +# Reserved keywords +abstract +become +box +do +final +macro +override +priv +typeof +unsized +virtual +yield +try + +# Primitive types +bool +char +str +i8 +i16 +i32 +i64 +i128 +isize +u8 +u16 +u32 +u64 +u128 +usize +f32 +f64 + +# Common standard library types +String +Vec +Option +Some +None +Result +Ok +Err +Box +Rc +Arc +Cell +RefCell +HashMap +HashSet +BTreeMap +BTreeSet + +# Common macros +println +print +eprintln +eprint +format +write +writeln +vec +panic +assert +assert_eq +assert_ne +dbg +todo +unimplemented +unreachable + diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/swift-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/swift-keywords.txt new file mode 100644 index 0000000000..5c1500cdb0 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/swift-keywords.txt @@ -0,0 +1,94 @@ +# Swift Keywords +# One keyword per line, case-sensitive +# Comments start with # + +# Reserved words +associatedtype +class +deinit +enum +extension +fileprivate +func +import +init +inout +internal +let +open +operator +private +precedencegroup +protocol +public +rethrows +static +struct +subscript +typealias +var +break +case +catch +continue +default +defer +do +else +fallthrough +for +guard +if +in +repeat +return +switch +throw +throws +try +where +while +Any +as +async +await +false +is +nil +self +Self +super +true +_ + +# Types +Int +Int8 +Int16 +Int32 +Int64 +UInt +UInt8 +UInt16 +UInt32 +UInt64 +Float +Double +Bool +String +Character +Optional +Array +Dictionary +Set +Void + +# Protocol names +Equatable +Hashable +Comparable +Codable +Encodable +Decodable +Identifiable +CustomStringConvertible diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-aggressive.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-aggressive.txt new file mode 100644 index 0000000000..1cc7c8f81a --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-aggressive.txt @@ -0,0 +1,132 @@ +# Aggressive Technical Stop Words +# Filters extensive technical jargon including design patterns +# One word per line, case-insensitive + +# Testing and utilities (from minimal) +test +tests +spec +specs +util +utils +helper +helpers +impl +implementation +config +configuration +tmp +temp +temporary + +# CRUD operations (from moderate) +get +set +create +read +update +delete +save +load +fetch +find + +# Architectural patterns (from moderate) +manager +service +handler +controller +processor +factory +builder + +# Error handling (from moderate) +error +exception + +# Common structure words (from moderate) +base +abstract +default +main +index +core +common +shared + +# Design patterns +singleton +adapter +observer +decorator +strategy +facade +proxy +command +iterator +mediator +visitor +template + +# Additional operations +execute +process +handle +invoke +call +run +start +stop +init +initialize +finalize +dispose + +# Data layer +model +view +entity +repository +dao +dto +pojo +bean + +# Logging and debugging +logger +log +logs +logging +debug +info +warn +warning +trace +context + +# Web/API terms +request +response +endpoint +route +middleware + +# General programming +method +function +param +params +parameter +parameters +arg +args +argument +arguments +result +results +value +values +type +types +expected +name diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-minimal.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-minimal.txt new file mode 100644 index 0000000000..81f38770cc --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-minimal.txt @@ -0,0 +1,23 @@ +# Minimal Technical Stop Words +# Filters only the most common technical/testing words +# One word per line, case-insensitive + +test +tests +spec +specs +util +utils +helper +helpers +impl +implementation +config +configuration +tmp +temp +temporary + +# Generic programming terms +expected +name diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-moderate.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-moderate.txt new file mode 100644 index 0000000000..2cc30175c2 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/technical-moderate.txt @@ -0,0 +1,59 @@ +# Moderate Technical Stop Words +# Filters common technical words, CRUD operations, and architectural patterns +# One word per line, case-insensitive + +# Testing and utilities (from minimal) +test +tests +spec +specs +util +utils +helper +helpers +impl +implementation +config +configuration +tmp +temp +temporary + +# CRUD operations +get +set +create +read +update +delete +save +load +fetch +find + +# Architectural patterns +manager +service +handler +controller +processor +factory +builder + +# Error handling +error +exception + +# Common structure words +base +abstract +default +main +index +core +common +shared + +# Generic programming terms +expected +name diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/typescript-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/typescript-keywords.txt new file mode 100644 index 0000000000..5b671e5118 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/typescript-keywords.txt @@ -0,0 +1,89 @@ +# TypeScript/JavaScript Keywords +# One keyword per line, case-sensitive +# Comments start with # + +# JavaScript reserved words +break +case +catch +class +const +continue +debugger +default +delete +do +else +export +extends +finally +for +function +if +import +in +instanceof +new +return +super +switch +this +throw +try +typeof +var +void +while +with +yield + +# JavaScript strict mode reserved words +let +static +implements +interface +package +private +protected +public + +# JavaScript literals +true +false +null +undefined + +# TypeScript-specific keywords +abstract +any +as +asserts +async +await +boolean +bigint +constructor +declare +enum +from +get +global +infer +is +keyof +module +namespace +never +number +object +of +out +override +readonly +require +set +string +symbol +type +unique +unknown diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/vue-keywords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/vue-keywords.txt new file mode 100644 index 0000000000..c646536737 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/keywords/vue-keywords.txt @@ -0,0 +1,225 @@ +# Vue Keywords +# One keyword per line, case-sensitive +# Comments start with # + +# JavaScript reserved words (Vue files contain JS/TS) +break +case +catch +class +const +continue +debugger +default +delete +do +else +export +extends +finally +for +function +if +import +in +instanceof +new +return +super +switch +this +throw +try +typeof +var +void +while +with +yield +let +static +implements +interface +package +private +protected +public +true +false +null +undefined + +# TypeScript keywords (commonly used with Vue) +abstract +any +as +async +await +boolean +declare +enum +from +get +infer +is +keyof +module +namespace +never +number +object +of +override +readonly +set +string +symbol +type +unknown + +# Vue template directives +v-if +v-else +v-else-if +v-for +v-on +v-bind +v-model +v-show +v-slot +v-pre +v-cloak +v-once +v-memo +v-html +v-text + +# Vue Options API +data +methods +computed +watch +props +emits +components +mixins +extends +provide +inject +name +inheritAttrs +directives +filters +setup +render +template +el +propsData +model + +# Vue lifecycle hooks (Options API) +beforeCreate +created +beforeMount +mounted +beforeUpdate +updated +beforeUnmount +unmounted +activated +deactivated +errorCaptured +renderTracked +renderTriggered +serverPrefetch + +# Vue Composition API +ref +reactive +computed +readonly +watchEffect +watchPostEffect +watchSyncEffect +watch +isRef +unref +toRef +toRefs +toValue +isProxy +isReactive +isReadonly +shallowRef +triggerRef +customRef +shallowReactive +shallowReadonly +toRaw +markRaw +effectScope +getCurrentScope +onScopeDispose +defineComponent +defineAsyncComponent +defineCustomElement + +# Vue Composition API lifecycle hooks +onBeforeMount +onMounted +onBeforeUpdate +onUpdated +onBeforeUnmount +onUnmounted +onActivated +onDeactivated +onErrorCaptured +onRenderTracked +onRenderTriggered +onServerPrefetch + +# Vue built-in components +component +transition +transition-group +keep-alive +slot +teleport +suspense + +# Vue special attributes +key +ref +is +scoped + +# Vue reactivity utilities +nextTick +defineProps +defineEmits +defineExpose +defineOptions +defineSlots +defineModel +withDefaults +useSlots +useAttrs +useCssModule +useCssVars + +# Vue Router common terms +router +route +useRouter +useRoute +RouterView +RouterLink + +# Pinia/Vuex common terms +store +useStore +state +getters +mutations +actions +modules diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/stopwords/english-stopwords.txt b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/stopwords/english-stopwords.txt new file mode 100644 index 0000000000..9abeb28a0e --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/main/resources/stopwords/english-stopwords.txt @@ -0,0 +1,99 @@ +# English Stop Words +# Common English words that should be filtered from word clouds +# One word per line, case-insensitive + +# Articles +a +an +the + +# Conjunctions +and +or +but + +# Prepositions +as +at +by +for +from +in +of +on +to +with + +# Pronouns +he +i +it +its +me +my +that +these +they +this +those +us +we +you + +# Verbs +are +be +can +could +do +has +if +is +may +might +must +no +not +shall +so +was +were +will +would + +# BDD/Test terminology +given +when +then +arrange +act +should +describe +expect +mock +spy +stub + +# Test fixture placeholders +foo +bar +baz +test +dummy +fake +sample +example + +# Short technical noise +src +log +err +tmp +ctx +val +res +req +cb +fn +idx +obj diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerFactoryTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerFactoryTest.kt new file mode 100644 index 0000000000..39a1d74442 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerFactoryTest.kt @@ -0,0 +1,90 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.AnalysisConfiguration +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.ExtractionWeights +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.Framework +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertTrue + +class SourceAnalyzerFactoryTest { + @Test + fun `should create analyzer with minimal configuration`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "test.kt").writeText("class Test") + val config = AnalysisConfiguration(allowedExtensions = listOf("kt")) + + // Act + val analyzer = SourceAnalyzerFactory.create(config) + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.filePaths.isNotEmpty()) + } + + @Test + fun `should create analyzer with full configuration`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "test.kt").writeText("class HelloWorld { fun greet() {} }") + val config = + AnalysisConfiguration( + allowedExtensions = listOf("kt", "java", "ts"), + bypassGitignore = true, + excludeTests = true, + languageKeywords = + listOf( + ResourceKeywords("keywords/kotlin-keywords.txt"), + ResourceKeywords("keywords/java-keywords.txt") + ), + weights = + ExtractionWeights( + identifierWeight = 5, + commentWeight = 3, + stringWeight = 1 + ), + ngrams = 2, + customStopWords = setOf("foo", "bar"), + frameworksByPath = mapOf(tempDir to setOf(Framework.REACT)), + enableSsr = false, + limit = 100, + outputFile = null, + enableTfidf = true + ) + + // Act + val analyzer = SourceAnalyzerFactory.create(config) + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.filePaths.isNotEmpty()) + } + + @Test + fun `should create functional analyzer that can analyze files`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "test.kt").writeText("class HelloWorld { fun greet() {} }") + val config = AnalysisConfiguration(allowedExtensions = listOf("kt")) + val analyzer = SourceAnalyzerFactory.create(config) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + val words = result.wordsByPath.values.flatten().map { it.text } + assertTrue(words.contains("hello")) + assertTrue(words.contains("world")) + assertTrue(words.contains("greet")) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerTest.kt new file mode 100644 index 0000000000..315b3ae46e --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/SourceAnalyzerTest.kt @@ -0,0 +1,615 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.AnalysisConfiguration +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli.SortBy +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.input.FileScanner +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.DomainAnalysisResult +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.WordFrequency +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.FileAnalyzer +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.FileProcessingResult +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.FileProcessor +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.FileResult +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.PathScopedKeywordProvider +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.StopWordFilter +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.analysis.TfIdfCalculator +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +// The root aggregation node produced by DirectoryWordAggregator. +private const val ROOT_KEY = "." + +private fun DomainAnalysisResult.allWords(): List = wordsByPath.values.flatten() + +private fun DomainAnalysisResult.hasWord(text: String): Boolean = allWords().any { it.text == text } + +private fun DomainAnalysisResult.rootWordOrder(): List = (wordsByPath[ROOT_KEY] ?: emptyList()).map { it.text } + +class SourceAnalyzerTest { + @Test + fun `should analyze directory and return domain words`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText( + """ + // hello world hello + class Hello { fun world() {} } + """.trimIndent() + ) + File(dir, "file2.kt").writeText( + """ + // world kotlin kotlin + class World { fun kotlin() {} } + """.trimIndent() + ) + + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("kt"))) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.hasWord("hello")) + assertTrue(result.hasWord("world")) + assertTrue(result.hasWord("kotlin")) + } + + @Test + fun `should filter stop words from analysis`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText( + """ + // the hello and world the kotlin + class Hello { fun world() {} fun kotlin() {} } + """.trimIndent() + ) + + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("kt"))) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.hasWord("hello")) + assertTrue(result.hasWord("world")) + assertTrue(result.hasWord("kotlin")) + assertFalse(result.hasWord("the")) + assertFalse(result.hasWord("and")) + } + + @Test + fun `should handle empty directory`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("kt"))) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.filePaths.isEmpty()) + assertTrue(result.wordsByPath.isEmpty()) + } + + @Test + fun `should sort results by frequency descending`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + // Use separate files to ensure clear frequency differences + File(dir, "file1.kt").writeText("class Apple {}") + File(dir, "file2.kt").writeText("class Apple {}") + File(dir, "file3.kt").writeText("class Apple {}") + File(dir, "file4.kt").writeText("class Banana {}") + File(dir, "file5.kt").writeText("class Banana {}") + File(dir, "file6.kt").writeText("class Cherry {}") + + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("kt"))) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert - apple appears 3x, banana 2x, cherry 1x aggregated at the root + val order = result.rootWordOrder() + val appleIndex = order.indexOf("apple") + val bananaIndex = order.indexOf("banana") + val cherryIndex = order.indexOf("cherry") + + assertTrue(appleIndex >= 0, "apple should be present") + assertTrue(bananaIndex >= 0, "banana should be present") + assertTrue(cherryIndex >= 0, "cherry should be present") + assertTrue(appleIndex < bananaIndex, "apple should come before banana") + assertTrue(bananaIndex < cherryIndex, "banana should come before cherry") + } + + @Test + fun `should limit results to top X words when limit is specified`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText( + """ + // apple apple apple banana banana cherry + class Apple { fun banana() {} fun cherry() {} } + """.trimIndent() + ) + + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("kt"), limit = 2)) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert - every node keeps at most `limit` words + assertTrue(result.wordsByPath.values.all { it.size <= 2 }) + assertTrue(result.hasWord("apple")) + assertTrue(result.hasWord("banana")) + assertFalse(result.hasWord("cherry")) + } + + @Test + fun `should return all results when limit is null`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText( + """ + // apple apple apple banana banana cherry + class Apple { fun banana() {} fun cherry() {} } + """.trimIndent() + ) + + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("kt"), limit = null)) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.hasWord("apple")) + assertTrue(result.hasWord("banana")) + assertTrue(result.hasWord("cherry")) + } + + @Test + fun `should filter language keywords when enabled`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.ts").writeText( + """ + // hello world kotlin + function hello() { const world = 'kotlin'; if (true) {} } + """.trimIndent() + ) + + val analyzer = + SourceAnalyzerFactory.create( + AnalysisConfiguration( + allowedExtensions = listOf("ts"), + languageKeywords = listOf(ResourceKeywords("keywords/typescript-keywords.txt")) + ) + ) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.hasWord("hello")) + assertTrue(result.hasWord("world")) + assertTrue(result.hasWord("kotlin")) + assertFalse(result.hasWord("function")) + assertFalse(result.hasWord("const")) + assertFalse(result.hasWord("if")) + } + + @Test + fun `should not filter language keywords when not provided`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.ts").writeText( + """ + function hello() { const world = 1; } + """.trimIndent() + ) + + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("ts"))) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert - identifiers should be present (function/const are keywords but appear as identifiers) + assertTrue(result.hasWord("hello")) + assertTrue(result.hasWord("world")) + } + + @Test + fun `should filter multiple language keywords when provided`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText( + """ + // hello world kotlin + class Hello { + fun world(): String { + val kotlin = "test" + return kotlin + } + } + """.trimIndent() + ) + + val analyzer = + SourceAnalyzerFactory.create( + AnalysisConfiguration( + allowedExtensions = listOf("kt"), + languageKeywords = + listOf( + ResourceKeywords("keywords/typescript-keywords.txt"), + ResourceKeywords("keywords/kotlin-keywords.txt") + ) + ) + ) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.hasWord("hello")) + assertTrue(result.hasWord("world")) + assertTrue(result.hasWord("kotlin")) + assertFalse(result.hasWord("fun")) + assertFalse(result.hasWord("val")) + assertFalse(result.hasWord("class")) + } + + @Test + fun `should extract domain words from Kotlin files with weighted analysis`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "UserProfile.kt").writeText( + """ + // Process customer data + class CustomerService { + fun processCustomer() { + } + } + """.trimIndent() + ) + + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("kt"))) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert - weighted extraction pulls domain words from class name, comment and function name + assertTrue(result.hasWord("customer")) + assertTrue(result.hasWord("service")) + assertTrue(result.hasWord("process")) + assertTrue(result.hasWord("data")) + } + + @Test + fun `should return empty result when files are deleted between scan and read`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "temporary.kt") + file.writeText("class Temporary") + + // A FileProcessor that deletes the file before reading and reports no words + val deletingProcessor = + object : FileProcessor { + override fun processFilesIndividually( + files: List, + basePath: String, + contentReader: (File) -> String, + processor: (File, String) -> FileResult, + onFileProcessed: (() -> Unit)? + ): FileProcessingResult { + files.forEach { it.delete() } + return FileProcessingResult(emptyMap(), emptyMap()) + } + } + + val config = AnalysisConfiguration(allowedExtensions = listOf("kt")) + val fileScanner = FileScanner(config.allowedExtensions) + val stopWordFilter = + StopWordFilter( + languageKeywords = emptyList(), + customStopWords = emptySet(), + pathScopedKeywordProvider = PathScopedKeywordProvider(emptyMap()) + ) + val fileAnalyzer = FileAnalyzer(stopWordFilter, config.weights, config.ngrams, config.enableSsr) + val analyzer = + SourceAnalyzer( + config = config, + fileScanner = fileScanner, + fileAnalyzer = fileAnalyzer, + fileProcessor = deletingProcessor, + tfIdfCalculator = TfIdfCalculator() + ) + + // Act - should not throw because the FileProcessor absorbs the deleted files + val result = analyzer.analyze(dir.absolutePath) + + // Assert - empty result since files were deleted + assertTrue(result.filePaths.isEmpty()) + assertTrue(result.wordsByPath.isEmpty()) + } + + @Test + fun `should correctly merge word counts from multiple files processed concurrently`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText( + """ + // apple banana cherry + class Apple { fun banana() {} fun cherry() {} } + """.trimIndent() + ) + File(dir, "file2.kt").writeText( + """ + // apple banana date + class Apple { fun banana() {} fun date() {} } + """.trimIndent() + ) + File(dir, "file3.kt").writeText( + """ + // apple elderberry + class Apple { fun elderberry() {} } + """.trimIndent() + ) + + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("kt"))) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.hasWord("apple")) + assertTrue(result.hasWord("banana")) + assertTrue(result.hasWord("cherry")) + assertTrue(result.hasWord("date")) + assertTrue(result.hasWord("elderberry")) + } + + @Test + fun `should produce deterministic results with concurrent processing`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText( + """ + // alpha beta gamma delta + class Alpha { fun beta() {} fun gamma() {} fun delta() {} } + """.trimIndent() + ) + File(dir, "file2.kt").writeText( + """ + // beta gamma delta epsilon + class Beta { fun gamma() {} fun delta() {} fun epsilon() {} } + """.trimIndent() + ) + File(dir, "file3.kt").writeText( + """ + // gamma delta epsilon zeta + class Gamma { fun delta() {} fun epsilon() {} fun zeta() {} } + """.trimIndent() + ) + + val analyzer = SourceAnalyzerFactory.create(AnalysisConfiguration(allowedExtensions = listOf("kt"))) + + // Act - run analysis multiple times + val result1 = analyzer.analyze(dir.absolutePath) + val result2 = analyzer.analyze(dir.absolutePath) + val result3 = analyzer.analyze(dir.absolutePath) + + // Assert - the word content per path is identical across runs + assertEquals(result1.wordsByPath, result2.wordsByPath) + assertEquals(result2.wordsByPath, result3.wordsByPath) + } + + @Test + fun `should filter technical stop words when moderate technical stop words are provided`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "Service.kt").writeText( + """ + // Process customer service requests + class CustomerService { + fun processRequest() { + val manager = ServiceManager() + manager.handleRequest() + } + } + """.trimIndent() + ) + + val analyzer = + SourceAnalyzerFactory.create( + AnalysisConfiguration( + allowedExtensions = listOf("kt"), + languageKeywords = + listOf( + ResourceKeywords("keywords/kotlin-keywords.txt"), + ResourceKeywords("keywords/technical-moderate.txt") + ) + ) + ) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert - domain words remain, technical stop words are filtered + assertTrue(result.hasWord("customer")) + assertFalse(result.hasWord("service")) + assertFalse(result.hasWord("manager")) + assertFalse(result.hasWord("handler")) + } + + @Test + fun `should not filter technical words when technical filtering is disabled`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "Service.kt").writeText( + """ + class CodeHelper { + fun utilManager() { + } + } + """.trimIndent() + ) + + val analyzer = + SourceAnalyzerFactory.create( + AnalysisConfiguration( + allowedExtensions = listOf("kt"), + languageKeywords = listOf(ResourceKeywords("keywords/kotlin-keywords.txt")) + ) + ) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert - technical words are present when not filtered + assertTrue(result.hasWord("code")) + assertTrue(result.hasWord("helper")) + assertTrue(result.hasWord("util")) + assertTrue(result.hasWord("manager")) + } + + @Test + fun `should include tfidf scores when enabled with multiple files`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText("class Domain { fun common() {} }") + File(dir, "file2.kt").writeText("class Other { fun common() {} }") + File(dir, "file3.kt").writeText("class Another { fun common() {} }") + + val analyzer = + SourceAnalyzerFactory.create( + AnalysisConfiguration( + allowedExtensions = listOf("kt"), + enableTfidf = true + ) + ) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.allWords().any { it.tfidf != null }) + } + + @Test + fun `should not include tfidf when disabled`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText("class Domain { fun common() {} }") + File(dir, "file2.kt").writeText("class Other { fun common() {} }") + + val analyzer = + SourceAnalyzerFactory.create( + AnalysisConfiguration( + allowedExtensions = listOf("kt"), + enableTfidf = false + ) + ) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.allWords().all { it.tfidf == null }) + } + + @Test + fun `should not include tfidf for single file project`( + @TempDir tempDir: Path + ) { + // Arrange - TF-IDF is undefined for a single file + val dir = tempDir.toFile() + File(dir, "file1.kt").writeText("class Domain { fun process() {} }") + + val analyzer = + SourceAnalyzerFactory.create( + AnalysisConfiguration( + allowedExtensions = listOf("kt"), + enableTfidf = true + ) + ) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert + assertTrue(result.allWords().all { it.tfidf == null }) + } + + @Test + fun `should sort by tfidf when sortBy is TFIDF`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + // "rare" appears in 1 file, "common" appears in all 5 files + File(dir, "file1.kt").writeText("class Rare { fun common() {} }") + File(dir, "file2.kt").writeText("class Other { fun common() {} }") + File(dir, "file3.kt").writeText("class Third { fun common() {} }") + File(dir, "file4.kt").writeText("class Fourth { fun common() {} }") + File(dir, "file5.kt").writeText("class Fifth { fun common() {} }") + + val analyzer = + SourceAnalyzerFactory.create( + AnalysisConfiguration( + allowedExtensions = listOf("kt"), + enableTfidf = true, + sortBy = SortBy.TFIDF + ) + ) + + // Act + val result = analyzer.analyze(dir.absolutePath) + + // Assert - "rare" should appear before "common" when sorted by TF-IDF at the root + val order = result.rootWordOrder() + val rareIndex = order.indexOf("rare") + val commonIndex = order.indexOf("common") + assertTrue(rareIndex >= 0, "rare should be present") + assertTrue(commonIndex >= 0, "common should be present") + assertTrue(rareIndex < commonIndex, "rare should come before common when sorted by TF-IDF") + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/WordAnalyzerTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/WordAnalyzerTest.kt new file mode 100644 index 0000000000..1d178f7656 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/WordAnalyzerTest.kt @@ -0,0 +1,28 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.DomainAnalysisResult +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.WordFrequency +import kotlin.test.Test +import kotlin.test.assertEquals + +class WordAnalyzerTest { + @Test + fun `should allow mock implementation for testing`() { + // Arrange + val mockResult = + DomainAnalysisResult( + filePaths = listOf("Mock.kt"), + wordsByPath = mapOf("Mock.kt" to listOf(WordFrequency(text = "mock", frequency = 1))) + ) + val mockAnalyzer = + object : WordAnalyzer { + override fun analyze(directoryPath: String): DomainAnalysisResult = mockResult + } + + // Act + val result = mockAnalyzer.analyze("/test/path") + + // Assert + assertEquals(mockResult, result) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/AnalysisConfigurationTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/AnalysisConfigurationTest.kt new file mode 100644 index 0000000000..8e525ec693 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/AnalysisConfigurationTest.kt @@ -0,0 +1,174 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.ExtractionWeights +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AnalysisConfigurationTest { + @Test + fun `should create configuration with all properties`() { + // Arrange + val extensions = listOf("kt", "ts", "java") + val keywords = + listOf( + ResourceKeywords("keywords/kotlin-keywords.txt"), + ResourceKeywords("keywords/typescript-keywords.txt") + ) + + // Act + val config = + AnalysisConfiguration( + allowedExtensions = extensions, + languageKeywords = keywords, + weights = + ExtractionWeights( + identifierWeight = 5, + commentWeight = 3, + stringWeight = 2 + ), + ngrams = 1, + customStopWords = emptySet(), + limit = 100, + outputFile = "output.json", + bypassGitignore = true, + excludeTests = true + ) + + // Assert + assertEquals(extensions, config.allowedExtensions) + assertEquals(keywords, config.languageKeywords) + assertEquals(5, config.weights.identifierWeight) + assertEquals(3, config.weights.commentWeight) + assertEquals(2, config.weights.stringWeight) + assertEquals(100, config.limit) + assertEquals("output.json", config.outputFile) + assertEquals(true, config.bypassGitignore) + assertEquals(true, config.excludeTests) + } + + @Test + fun `should create configuration with default values`() { + // Arrange & Act + val config = AnalysisConfiguration(allowedExtensions = listOf("kt")) + + // Assert + assertTrue(config.languageKeywords.isEmpty()) + assertEquals(ExtractionWeights(), config.weights) + assertEquals(1, config.ngrams) + assertTrue(config.customStopWords.isEmpty()) + assertTrue(config.frameworksByPath.isEmpty()) + assertEquals(true, config.enableSsr) + assertNull(config.limit) + assertNull(config.outputFile) + assertEquals(false, config.bypassGitignore) + assertEquals(false, config.excludeTests) + assertEquals(true, config.enableTfidf) + assertEquals(SortBy.FREQUENCY, config.sortBy) + } + + @Test + fun `should reject empty allowedExtensions`() { + // Arrange & Act & Assert + assertFailsWith { + AnalysisConfiguration(allowedExtensions = emptyList()) + }.also { ex -> + assertEquals("At least one file extension must be specified", ex.message) + } + } + + @Test + fun `should reject ngrams less than 1`() { + // Arrange & Act & Assert + assertFailsWith { + AnalysisConfiguration( + allowedExtensions = listOf("kt"), + ngrams = 0 + ) + }.also { ex -> + assertEquals("ngrams must be at least 1, got 0", ex.message) + } + } + + @Test + fun `should reject blank extensions`() { + // Arrange & Act & Assert + assertFailsWith { + AnalysisConfiguration(allowedExtensions = listOf("kt", " ", "ts")) + }.also { ex -> + assertEquals("Extensions cannot be blank", ex.message) + } + } + + @Test + fun `should support data class equality`() { + // Arrange + val extensions = listOf("kt", "ts") + val keywords = listOf(ResourceKeywords("keywords/kotlin-keywords.txt")) + val config1 = + AnalysisConfiguration( + allowedExtensions = extensions, + languageKeywords = keywords, + weights = ExtractionWeights(), + ngrams = 1, + customStopWords = emptySet() + ) + val config2 = + AnalysisConfiguration( + allowedExtensions = extensions, + languageKeywords = keywords, + weights = ExtractionWeights(), + ngrams = 1, + customStopWords = emptySet() + ) + + // Act & Assert + assertEquals(config1, config2) + } + + @Test + fun `should differentiate between different configurations`() { + // Arrange + val config1 = AnalysisConfiguration(allowedExtensions = listOf("kt")) + val config2 = AnalysisConfiguration(allowedExtensions = listOf("ts")) + + // Act & Assert + assertNotEquals(config1, config2) + } + + @Test + fun `should support data class copy`() { + // Arrange + val original = + AnalysisConfiguration( + allowedExtensions = listOf("kt"), + languageKeywords = listOf(ResourceKeywords("keywords/kotlin-keywords.txt")), + weights = ExtractionWeights(), + ngrams = 1, + customStopWords = emptySet() + ) + + // Act + val modified = + original.copy( + weights = + ExtractionWeights( + identifierWeight = 5, + commentWeight = 4, + stringWeight = 1 + ), + limit = 50 + ) + + // Assert + assertEquals(listOf("kt"), modified.allowedExtensions) + assertEquals(5, modified.weights.identifierWeight) + assertEquals(4, modified.weights.commentWeight) + assertEquals(1, modified.weights.stringWeight) + assertEquals(50, modified.limit) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ConfigurationBuilderTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ConfigurationBuilderTest.kt new file mode 100644 index 0000000000..0c4ca381c0 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ConfigurationBuilderTest.kt @@ -0,0 +1,475 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.Framework +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.Language +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConfigurationBuilderTest { + private val builder = ConfigurationBuilder() + + @Test + fun `should build configuration with default values`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert + assertEquals(3, config.weights.identifierWeight) + assertEquals(2, config.weights.commentWeight) + assertEquals(1, config.weights.stringWeight) + } + + @Test + fun `should build configuration with custom weights`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 10, + commentWeight = 5, + stringWeight = 3, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert + assertEquals(10, config.weights.identifierWeight) + assertEquals(5, config.weights.commentWeight) + assertEquals(3, config.weights.stringWeight) + } + + @Test + fun `should include all supported file extensions`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - should include all extensions from Language enum + val expectedExtensions = Language.allExtensions() + assertEquals(expectedExtensions, config.allowedExtensions) + } + + @Test + fun `should include all language keywords by default`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert (no framework keywords when package.json doesn't exist) + // Should have 3 core language keywords + 1 technical stop words = 4 + assertEquals(4, config.languageKeywords.size) + assertTrue(config.languageKeywords.all { it is ResourceKeywords }) + // Verify keywords are loaded correctly + val allKeywords = config.languageKeywords.flatMap { it.getKeywords() } + assertTrue(allKeywords.contains("class")) // Java/Kotlin keyword + assertTrue(allKeywords.contains("fun")) // Kotlin keyword + assertTrue(allKeywords.contains("interface")) // TypeScript keyword + } + + @Test + fun `should include technical stop words when not excluded`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - 4 keywords providers (3 languages + 1 technical stop words) + assertEquals(4, config.languageKeywords.size) + } + + @Test + fun `should exclude technical stop words when requested`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = true, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - only 3 core language keywords, no technical stop words + assertEquals(3, config.languageKeywords.size) + } + + @Test + fun `should use minimal technical stop words when level is minimal`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MINIMAL, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - 4 keyword providers including minimal stop words + assertEquals(4, config.languageKeywords.size) + } + + @Test + fun `should use moderate technical stop words when level is moderate`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - 4 keyword providers including moderate stop words + assertEquals(4, config.languageKeywords.size) + } + + @Test + fun `should use aggressive technical stop words when level is aggressive`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.AGGRESSIVE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - 4 keyword providers including aggressive stop words + assertEquals(4, config.languageKeywords.size) + } + + @Test + fun `should transfer execution settings to configuration`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = "/path", + limit = 50, + bypassGitignore = true, + excludeTests = true, + output = "output.json", + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - execution settings are now in the unified config + assertEquals(50, config.limit) + assertEquals(true, config.bypassGitignore) + assertEquals(true, config.excludeTests) + assertEquals("output.json", config.outputFile) + } + + @Test + fun `should detect React framework and store in frameworksByPath`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "react-project", + "dependencies": { + "react": "^18.0.0" + } + } + """.trimIndent() + ) + + val parsedArgs = + ParsedArguments( + directory = tempDir.toString(), + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - framework keywords are now path-scoped, not in languageKeywords + assertEquals(4, config.languageKeywords.size) // 3 core languages + 1 technical stop words + assertTrue(config.frameworksByPath.isNotEmpty()) + assertTrue(config.frameworksByPath[tempDir]?.contains(Framework.REACT) == true) + assertFalse(config.frameworksByPath[tempDir]?.contains(Framework.ANGULAR) == true) + } + + @Test + fun `should detect Angular framework and store in frameworksByPath`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "angular-project", + "dependencies": { + "@angular/core": "^17.0.0" + } + } + """.trimIndent() + ) + + val parsedArgs = + ParsedArguments( + directory = tempDir.toString(), + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - framework keywords are now path-scoped, not in languageKeywords + assertEquals(4, config.languageKeywords.size) // 3 core languages + 1 technical stop words + assertTrue(config.frameworksByPath.isNotEmpty()) + assertTrue(config.frameworksByPath[tempDir]?.contains(Framework.ANGULAR) == true) + assertFalse(config.frameworksByPath[tempDir]?.contains(Framework.REACT) == true) + } + + @Test + fun `should detect both React and Angular frameworks in frameworksByPath`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "multi-framework-project", + "dependencies": { + "react": "^18.0.0", + "@angular/core": "^17.0.0" + } + } + """.trimIndent() + ) + + val parsedArgs = + ParsedArguments( + directory = tempDir.toString(), + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - framework keywords are now path-scoped, not in languageKeywords + assertEquals(4, config.languageKeywords.size) // 3 core languages + 1 technical stop words + assertTrue(config.frameworksByPath.isNotEmpty()) + assertTrue(config.frameworksByPath[tempDir]?.contains(Framework.REACT) == true) + assertTrue(config.frameworksByPath[tempDir]?.contains(Framework.ANGULAR) == true) + } + + @Test + fun `should have empty frameworksByPath when no framework is detected`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "other-project", + "dependencies": { + "express": "^4.18.0" + } + } + """.trimIndent() + ) + + val parsedArgs = + ParsedArguments( + directory = tempDir.toString(), + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val config = builder.build(parsedArgs) + + // Assert - no frameworks detected, frameworksByPath should be empty + assertEquals(4, config.languageKeywords.size) // 3 core languages + 1 technical stop words + assertTrue(config.frameworksByPath.isEmpty()) + } + + @Test + fun `should throw helpful error when directory is null`() { + // Arrange + val parsedArgs = + ParsedArguments( + directory = null, + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act & Assert + val exception = + assertFailsWith { + builder.build(parsedArgs) + } + assertEquals("Please provide a directory with -d flag", exception.message) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ParsedArgumentsTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ParsedArgumentsTest.kt new file mode 100644 index 0000000000..ca0951e8d7 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/cli/ParsedArgumentsTest.kt @@ -0,0 +1,160 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.cli + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class ParsedArgumentsTest { + @Test + fun `should create ParsedArguments with all properties`() { + // Arrange & Act + val args = + ParsedArguments( + directory = "/path/to/dir", + limit = 100, + bypassGitignore = true, + excludeTests = false, + output = "output.json", + identifierWeight = 5, + commentWeight = 3, + stringWeight = 2, + excludeTechnicalStopWords = true, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Assert + assertEquals("/path/to/dir", args.directory) + assertEquals(100, args.limit) + assertEquals(true, args.bypassGitignore) + assertEquals("output.json", args.output) + assertEquals(5, args.identifierWeight) + assertEquals(3, args.commentWeight) + assertEquals(2, args.stringWeight) + assertEquals(true, args.excludeTechnicalStopWords) + } + + @Test + fun `should create ParsedArguments with null optional values`() { + // Arrange & Act + val args = + ParsedArguments( + directory = null, + limit = null, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Assert + assertEquals(null, args.directory) + assertEquals(null, args.limit) + assertEquals(null, args.output) + } + + @Test + fun `should support data class equality`() { + // Arrange + val args1 = + ParsedArguments( + directory = "/path", + limit = 50, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + val args2 = + ParsedArguments( + directory = "/path", + limit = 50, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act & Assert + assertEquals(args1, args2) + } + + @Test + fun `should differentiate between different ParsedArguments`() { + // Arrange + val args1 = + ParsedArguments( + directory = "/path1", + limit = 50, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + val args2 = + ParsedArguments( + directory = "/path2", + limit = 50, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act & Assert + assertNotEquals(args1, args2) + } + + @Test + fun `should support data class copy`() { + // Arrange + val original = + ParsedArguments( + directory = "/original", + limit = 50, + bypassGitignore = false, + excludeTests = false, + output = null, + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1, + excludeTechnicalStopWords = false, + stopWordLevel = StopWordLevel.MODERATE, + ngrams = 1 + ) + + // Act + val modified = original.copy(directory = "/modified", limit = 100) + + // Assert + assertEquals("/modified", modified.directory) + assertEquals(100, modified.limit) + assertEquals(false, modified.bypassGitignore) + assertEquals(3, modified.identifierWeight) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileFilterTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileFilterTest.kt new file mode 100644 index 0000000000..698d5e1a11 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileFilterTest.kt @@ -0,0 +1,138 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.input + +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FileFilterTest { + @Test + fun `should return true when file extension matches`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.txt") + file.writeText("content") + + val filter = FileFilter(listOf("txt", "md")) + + // Act + val result = filter.matchesExtension(file) + + // Assert + assertTrue(result) + } + + @Test + fun `should return false when file extension does not match`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.log") + file.writeText("content") + + val filter = FileFilter(listOf("txt", "md")) + + // Act + val result = filter.matchesExtension(file) + + // Assert + assertFalse(result) + } + + @Test + fun `should match extension case insensitively`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "TEST.TXT") + file.writeText("content") + + val filter = FileFilter(listOf("txt")) + + // Act + val result = filter.matchesExtension(file) + + // Assert + assertTrue(result) + } + + @Test + fun `should match any allowed extension`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val txtFile = File(dir, "file.txt") + val mdFile = File(dir, "file.md") + val ktFile = File(dir, "file.kt") + val logFile = File(dir, "file.log") + + val filter = FileFilter(listOf("txt", "md", "kt")) + + // Act & Assert + assertTrue(filter.matchesExtension(txtFile)) + assertTrue(filter.matchesExtension(mdFile)) + assertTrue(filter.matchesExtension(ktFile)) + assertFalse(filter.matchesExtension(logFile)) + } + + @Test + fun `should handle files with multiple dots`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.backup.txt") + file.writeText("content") + + val filter = FileFilter(listOf("txt")) + + // Act + val result = filter.matchesExtension(file) + + // Assert + assertTrue(result) + } + + @Test + fun `should return false for files without extension`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "README") + file.writeText("content") + + val filter = FileFilter(listOf("txt", "md")) + + // Act + val result = filter.matchesExtension(file) + + // Assert + assertFalse(result) + } + + @Test + fun `should match when extension list contains mixed case`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.TXT") + file.writeText("content") + + val filter = FileFilter(listOf("TXT", "MD")) + + // Act + val result = filter.matchesExtension(file) + + // Assert + assertTrue(result) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileScannerTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileScannerTest.kt new file mode 100644 index 0000000000..4521f4b321 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/FileScannerTest.kt @@ -0,0 +1,574 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.input + +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.charset.Charset +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FileScannerTest { + @Test + fun `should scan files with allowed extensions recursively`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "test1.txt").writeText("content1") + File(dir, "test2.md").writeText("content2") + File(dir, "test3.log").writeText("content3") + val subDir = File(dir, "subdir") + subDir.mkdir() + File(subDir, "test4.txt").writeText("content4") + + val scanner = FileScanner(allowedExtensions = listOf("txt", "md")) + + // Act + val files = scanner.scan(dir.absolutePath) + + // Assert + assertEquals(3, files.size) + assertTrue(files.any { it.name == "test1.txt" }) + assertTrue(files.any { it.name == "test2.md" }) + assertTrue(files.any { it.name == "test4.txt" }) + } + + @Test + fun `should read file contents`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.txt") + file.writeText("hello world\ntest content") + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val files = scanner.scan(dir.absolutePath) + val content = scanner.readFileContent(files.first()).getOrThrow() + + // Assert + assertEquals("hello world\ntest content", content) + } + + @Test + fun `should return empty list when no matching files found`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "test.log").writeText("content") + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val files = scanner.scan(dir.absolutePath) + + // Assert + assertEquals(0, files.size) + } + + @Test + fun `should return empty list when directory does not exist`() { + // Arrange + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val files = scanner.scan("/nonexistent/directory/path") + + // Assert + assertEquals(0, files.size) + } + + @Test + fun `should return empty list when path is a file not a directory`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.txt") + file.writeText("content") + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val files = scanner.scan(file.absolutePath) + + // Assert + assertEquals(0, files.size) + } + + @Test + fun `should ignore files matching gitignore patterns`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "keep.txt").writeText("keep") + File(dir, "ignore.log").writeText("ignore") + File(dir, "test.txt").writeText("test") + File(dir, ".gitignore").writeText("*.log") + + val scanner = FileScanner(allowedExtensions = listOf("txt", "log")) + + // Act + val files = scanner.scan(dir.absolutePath) + + // Assert + assertEquals(2, files.size) + assertTrue(files.any { it.name == "keep.txt" }) + assertTrue(files.any { it.name == "test.txt" }) + assertTrue(files.none { it.name == "ignore.log" }) + } + + @Test + fun `should ignore directories matching gitignore patterns`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "root.txt").writeText("root") + + val keepDir = File(dir, "keep") + keepDir.mkdir() + File(keepDir, "file.txt").writeText("keep") + + val ignoreDir = File(dir, "build") + ignoreDir.mkdir() + File(ignoreDir, "output.txt").writeText("ignore") + + File(dir, ".gitignore").writeText("build/") + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val files = scanner.scan(dir.absolutePath) + + // Assert + assertEquals(2, files.size) + assertTrue(files.any { it.name == "root.txt" }) + assertTrue(files.any { it.name == "file.txt" }) + assertTrue(files.none { it.name == "output.txt" }) + } + + @Test + fun `should respect nested gitignore files`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "root.txt").writeText("root") + File(dir, ".gitignore").writeText("*.log") + + val subDir = File(dir, "subdir") + subDir.mkdir() + File(subDir, "sub.txt").writeText("sub") + File(subDir, "debug.log").writeText("ignore") + File(subDir, "temp.tmp").writeText("also ignore") + File(subDir, ".gitignore").writeText("*.tmp") + + val scanner = FileScanner(allowedExtensions = listOf("txt", "log", "tmp")) + + // Act + val files = scanner.scan(dir.absolutePath) + + // Assert + assertEquals(2, files.size) + assertTrue(files.any { it.name == "root.txt" }) + assertTrue(files.any { it.name == "sub.txt" }) + assertTrue(files.none { it.name == "debug.log" }) + assertTrue(files.none { it.name == "temp.tmp" }) + } + + @Test + fun `should scan normally when gitignore is bypassed`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "keep.txt").writeText("keep") + File(dir, "ignore.log").writeText("also keep") + File(dir, ".gitignore").writeText("*.log") + + val scanner = FileScanner(allowedExtensions = listOf("txt", "log")) + + // Act + val files = scanner.scan(dir.absolutePath, bypassGitignore = true) + + // Assert + assertEquals(2, files.size) + assertTrue(files.any { it.name == "keep.txt" }) + assertTrue(files.any { it.name == "ignore.log" }) + } + + @Test + fun `should read file with UTF-8 encoding`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.txt") + val content = "Hello 世界 🌍" + file.writeText(content, Charsets.UTF_8) + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val result = scanner.readFileContent(file, Charsets.UTF_8).getOrThrow() + + // Assert + assertEquals(content, result) + } + + @Test + fun `should read file with UTF-16 encoding`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.txt") + val content = "Hello UTF-16" + file.writeText(content, Charsets.UTF_16) + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val result = scanner.readFileContent(file, Charsets.UTF_16).getOrThrow() + + // Assert + assertEquals(content, result) + } + + @Test + fun `should read file with ISO-8859-1 encoding`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.txt") + val content = "Hello ISO-8859-1" + file.writeText(content, Charset.forName("ISO-8859-1")) + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val result = scanner.readFileContent(file, Charset.forName("ISO-8859-1")).getOrThrow() + + // Assert + assertEquals(content, result) + } + + @Test + fun `should read file using default UTF-8 encoding when charset not specified`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.txt") + val content = "Hello world" + file.writeText(content, Charsets.UTF_8) + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val result = scanner.readFileContent(file).getOrThrow() + + // Assert + assertEquals(content, result) + } + + @Test + fun `should handle large file efficiently`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "large.txt") + // Create a file with ~1MB of content + val largeContent = "a".repeat(1_000_000) + file.writeText(largeContent) + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val result = scanner.readFileContent(file).getOrThrow() + + // Assert + assertEquals(1_000_000, result.length) + } + + @Test + fun `should return failure when reading non-existent file`() { + // Arrange + val scanner = FileScanner(allowedExtensions = listOf("txt")) + val nonExistentFile = File("/nonexistent/path/to/file.txt") + + // Act + val result = scanner.readFileContent(nonExistentFile) + + // Assert + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun `should return failure when reading directory instead of file`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val result = scanner.readFileContent(dir) + + // Assert + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun `should return failure when file deleted after scan but before read`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.txt") + file.writeText("content") + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val files = scanner.scan(dir.absolutePath) + assertEquals(1, files.size) + + // Delete file after scan + file.delete() + + // Assert + val result = scanner.readFileContent(files.first()) + assertTrue(result.isFailure) + } + + @Test + fun `should handle file modified concurrently during read`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val file = File(dir, "test.txt") + val originalContent = "original content" + file.writeText(originalContent) + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val files = scanner.scan(dir.absolutePath) + assertEquals(1, files.size) + + // Modify file after scan + file.writeText("modified content") + + // Assert - should read modified content without error + val content = scanner.readFileContent(files.first()).getOrThrow() + assertEquals("modified content", content) + } + + @Test + fun `should handle directory structure changes after scan`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "file1.txt").writeText("content1") + File(dir, "file2.txt").writeText("content2") + + val scanner = FileScanner(allowedExtensions = listOf("txt")) + + // Act + val files = scanner.scan(dir.absolutePath) + assertEquals(2, files.size) + + // Add new directory and file after scan + val newDir = File(dir, "newdir") + newDir.mkdir() + File(newDir, "file3.txt").writeText("content3") + + // Assert - original scan results remain valid + assertTrue(files.all { it.exists() }) + assertEquals(2, files.size) + + // New scan should pick up the changes + val newScan = scanner.scan(dir.absolutePath) + assertEquals(3, newScan.size) + } + + @Test + fun `should exclude test files when excludeTests is true`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "User.kt").writeText("class User") + File(dir, "UserTest.kt").writeText("class UserTest") + File(dir, "Service.kt").writeText("class Service") + File(dir, "ServiceTest.kt").writeText("class ServiceTest") + + val scanner = FileScanner(allowedExtensions = listOf("kt")) + + // Act + val files = scanner.scan(dir.absolutePath, bypassGitignore = false, excludeTests = true) + + // Assert + assertEquals(2, files.size) + assertTrue(files.any { it.name == "User.kt" }) + assertTrue(files.any { it.name == "Service.kt" }) + assertFalse(files.any { it.name == "UserTest.kt" }) + assertFalse(files.any { it.name == "ServiceTest.kt" }) + } + + @Test + fun `should include test files when excludeTests is false`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "User.kt").writeText("class User") + File(dir, "UserTest.kt").writeText("class UserTest") + + val scanner = FileScanner(allowedExtensions = listOf("kt")) + + // Act + val files = scanner.scan(dir.absolutePath, bypassGitignore = false, excludeTests = false) + + // Assert + assertEquals(2, files.size) + assertTrue(files.any { it.name == "User.kt" }) + assertTrue(files.any { it.name == "UserTest.kt" }) + } + + @Test + fun `should exclude files in test directories when excludeTests is true`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testDir = File(dir, "test") + testDir.mkdir() + File(dir, "User.kt").writeText("class User") + File(testDir, "UserTest.kt").writeText("class UserTest") + + val scanner = FileScanner(allowedExtensions = listOf("kt")) + + // Act + val files = scanner.scan(dir.absolutePath, bypassGitignore = false, excludeTests = true) + + // Assert + assertEquals(1, files.size) + assertTrue(files.any { it.name == "User.kt" }) + assertFalse(files.any { it.name == "UserTest.kt" }) + } + + @Test + fun `should exclude TypeScript test files with test pattern when excludeTests is true`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "user.ts").writeText("export class User") + File(dir, "user.test.ts").writeText("describe('User')") + File(dir, "service.spec.ts").writeText("describe('Service')") + + val scanner = FileScanner(allowedExtensions = listOf("ts")) + + // Act + val files = scanner.scan(dir.absolutePath, bypassGitignore = false, excludeTests = true) + + // Assert + assertEquals(1, files.size) + assertTrue(files.any { it.name == "user.ts" }) + assertFalse(files.any { it.name == "user.test.ts" }) + assertFalse(files.any { it.name == "service.spec.ts" }) + } + + @Test + fun `should exclude Python test files when excludeTests is true`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "user.py").writeText("class User") + File(dir, "test_user.py").writeText("def test_user()") + File(dir, "service_test.py").writeText("def test_service()") + + val scanner = FileScanner(allowedExtensions = listOf("py")) + + // Act + val files = scanner.scan(dir.absolutePath, bypassGitignore = false, excludeTests = true) + + // Assert + assertEquals(1, files.size) + assertTrue(files.any { it.name == "user.py" }) + assertFalse(files.any { it.name == "test_user.py" }) + assertFalse(files.any { it.name == "service_test.py" }) + } + + @Test + fun `should exclude test files when both bypassGitignore and excludeTests are true`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + File(dir, "User.kt").writeText("class User") + File(dir, "UserTest.kt").writeText("class UserTest") + File(dir, ".gitignore").writeText("*.kt") + + val scanner = FileScanner(allowedExtensions = listOf("kt")) + + // Act + val files = scanner.scan(dir.absolutePath, bypassGitignore = true, excludeTests = true) + + // Assert + assertEquals(1, files.size) + assertTrue(files.any { it.name == "User.kt" }) + assertFalse(files.any { it.name == "UserTest.kt" }) + } + + @Test + fun `should exclude files in multiple test directory types when excludeTests is true`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testDir = File(dir, "test") + val testsDir = File(dir, "tests") + val testsDunderDir = File(dir, "__tests__") + val specDir = File(dir, "spec") + val specsDir = File(dir, "specs") + + testDir.mkdir() + testsDir.mkdir() + testsDunderDir.mkdir() + specDir.mkdir() + specsDir.mkdir() + + File(dir, "User.kt").writeText("class User") + File(testDir, "Test1.kt").writeText("test1") + File(testsDir, "Test2.kt").writeText("test2") + File(testsDunderDir, "Test3.kt").writeText("test3") + File(specDir, "Test4.kt").writeText("test4") + File(specsDir, "Test5.kt").writeText("test5") + + val scanner = FileScanner(allowedExtensions = listOf("kt")) + + // Act + val files = scanner.scan(dir.absolutePath, bypassGitignore = false, excludeTests = true) + + // Assert + assertEquals(1, files.size) + assertTrue(files.any { it.name == "User.kt" }) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/TestFileDetectorTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/TestFileDetectorTest.kt new file mode 100644 index 0000000000..dd3cc012d5 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/input/TestFileDetectorTest.kt @@ -0,0 +1,346 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.input + +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TestFileDetectorTest { + private val detector = TestFileDetector() + + @Test + fun `should detect Kotlin test files ending with Test`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testFile1 = File(dir, "UserTest.kt") + val testFile2 = File(dir, "ServiceTest.kts") + testFile1.createNewFile() + testFile2.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(testFile1)) + assertTrue(detector.isTestFile(testFile2)) + } + + @Test + fun `should not detect regular Kotlin files as tests`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val regularFile1 = File(dir, "User.kt") + val regularFile2 = File(dir, "Service.kts") + val withTestInName = File(dir, "UserTestHelper.kt") + regularFile1.createNewFile() + regularFile2.createNewFile() + withTestInName.createNewFile() + + // Act & Assert + assertFalse(detector.isTestFile(regularFile1)) + assertFalse(detector.isTestFile(regularFile2)) + assertFalse(detector.isTestFile(withTestInName)) + } + + @Test + fun `should detect TypeScript test files with test pattern`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testFiles = + listOf( + File(dir, "user.test.ts"), + File(dir, "service.test.tsx"), + File(dir, "handler.test.js"), + File(dir, "component.test.jsx"), + File(dir, "module.test.cjs"), + File(dir, "util.test.mjs"), + File(dir, "types.test.cts"), + File(dir, "config.test.mts") + ) + testFiles.forEach { it.createNewFile() } + + // Act & Assert + testFiles.forEach { file -> + assertTrue(detector.isTestFile(file), "Expected ${file.name} to be detected as test file") + } + } + + @Test + fun `should detect TypeScript spec files with spec pattern`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val specFiles = + listOf( + File(dir, "user.spec.ts"), + File(dir, "service.spec.tsx"), + File(dir, "handler.spec.js"), + File(dir, "component.spec.jsx") + ) + specFiles.forEach { it.createNewFile() } + + // Act & Assert + specFiles.forEach { file -> + assertTrue(detector.isTestFile(file), "Expected ${file.name} to be detected as spec file") + } + } + + @Test + fun `should not detect regular TypeScript files as tests`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val regularFiles = + listOf( + File(dir, "user.ts"), + File(dir, "service.tsx"), + File(dir, "handler.js"), + File(dir, "component.jsx") + ) + regularFiles.forEach { it.createNewFile() } + + // Act & Assert + regularFiles.forEach { file -> + assertFalse(detector.isTestFile(file), "Expected ${file.name} to NOT be detected as test file") + } + } + + @Test + fun `should detect Java test files ending with Test`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testFile = File(dir, "UserTest.java") + testFile.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(testFile)) + } + + @Test + fun `should not detect regular Java files as tests`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val regularFile = File(dir, "User.java") + val withTestInName = File(dir, "UserTestHelper.java") + regularFile.createNewFile() + withTestInName.createNewFile() + + // Act & Assert + assertFalse(detector.isTestFile(regularFile)) + assertFalse(detector.isTestFile(withTestInName)) + } + + @Test + fun `should detect Python test files with test prefix`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testFile1 = File(dir, "test_user.py") + val testFile2 = File(dir, "test_service.py") + testFile1.createNewFile() + testFile2.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(testFile1)) + assertTrue(detector.isTestFile(testFile2)) + } + + @Test + fun `should detect Python test files with test suffix`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testFile1 = File(dir, "user_test.py") + val testFile2 = File(dir, "service_test.py") + testFile1.createNewFile() + testFile2.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(testFile1)) + assertTrue(detector.isTestFile(testFile2)) + } + + @Test + fun `should not detect regular Python files as tests`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val regularFile = File(dir, "user.py") + val withTestInMiddle = File(dir, "user_test_helper.py") + regularFile.createNewFile() + withTestInMiddle.createNewFile() + + // Act & Assert + assertFalse(detector.isTestFile(regularFile)) + assertFalse(detector.isTestFile(withTestInMiddle)) + } + + @Test + fun `should detect files in test directory`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testDir = File(dir, "test") + testDir.mkdir() + val fileInTestDir = File(testDir, "User.kt") + fileInTestDir.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(fileInTestDir)) + } + + @Test + fun `should detect files in tests directory`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testsDir = File(dir, "tests") + testsDir.mkdir() + val fileInTestsDir = File(testsDir, "Service.java") + fileInTestsDir.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(fileInTestsDir)) + } + + @Test + fun `should detect files in __tests__ directory`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testsDir = File(dir, "__tests__") + testsDir.mkdir() + val fileInTestsDir = File(testsDir, "component.tsx") + fileInTestsDir.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(fileInTestsDir)) + } + + @Test + fun `should detect files in spec directory`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val specDir = File(dir, "spec") + specDir.mkdir() + val fileInSpecDir = File(specDir, "user.ts") + fileInSpecDir.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(fileInSpecDir)) + } + + @Test + fun `should detect files in specs directory`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val specsDir = File(dir, "specs") + specsDir.mkdir() + val fileInSpecsDir = File(specsDir, "service.js") + fileInSpecsDir.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(fileInSpecsDir)) + } + + @Test + fun `should detect files in nested test directories`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val srcDir = File(dir, "src") + srcDir.mkdir() + val testDir = File(srcDir, "test") + testDir.mkdir() + val kotlinDir = File(testDir, "kotlin") + kotlinDir.mkdir() + val fileInNestedTestDir = File(kotlinDir, "User.kt") + fileInNestedTestDir.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(fileInNestedTestDir)) + } + + @Test + fun `should not detect files in non-test directories`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val srcDir = File(dir, "src") + srcDir.mkdir() + val mainDir = File(srcDir, "main") + mainDir.mkdir() + val fileInMainDir = File(mainDir, "User.kt") + fileInMainDir.createNewFile() + + // Act & Assert + assertFalse(detector.isTestFile(fileInMainDir)) + } + + @Test + fun `should be case insensitive for Kotlin test files`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testFile1 = File(dir, "UserTest.KT") + val testFile2 = File(dir, "ServiceTest.KTS") + testFile1.createNewFile() + testFile2.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(testFile1)) + assertTrue(detector.isTestFile(testFile2)) + } + + @Test + fun `should be case insensitive for TypeScript test files`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testFile = File(dir, "user.test.TS") + testFile.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(testFile)) + } + + @Test + fun `should detect test files with both directory and filename patterns`( + @TempDir tempDir: Path + ) { + // Arrange + val dir = tempDir.toFile() + val testDir = File(dir, "test") + testDir.mkdir() + val testFile = File(testDir, "UserTest.kt") + testFile.createNewFile() + + // Act & Assert + assertTrue(detector.isTestFile(testFile)) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DirectoryWordAggregatorTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DirectoryWordAggregatorTest.kt new file mode 100644 index 0000000000..0d870c3636 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DirectoryWordAggregatorTest.kt @@ -0,0 +1,147 @@ +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.DirectoryWordAggregator +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output.WordFrequency +import kotlin.test.Test +import kotlin.test.assertEquals + +class DirectoryWordAggregatorTest { + @Test + fun `should aggregate words from single file`() { + // Arrange + val fileWords = + mapOf( + "src/Main.kt" to + listOf( + WordFrequency("file", 10), + WordFrequency("word", 5) + ) + ) + + // Act + val result = DirectoryWordAggregator.aggregateDirectories(fileWords) + + // Assert + assertEquals(3, result.size) // ., src, src/Main.kt + assertEquals( + listOf(WordFrequency("file", 10), WordFrequency("word", 5)), + result["src/Main.kt"] + ) + assertEquals( + listOf(WordFrequency("file", 10), WordFrequency("word", 5)), + result["src"] + ) + assertEquals( + listOf(WordFrequency("file", 10), WordFrequency("word", 5)), + result["."] + ) + } + + @Test + fun `should aggregate words from multiple files in same directory`() { + // Arrange + val fileWords = + mapOf( + "src/Main.kt" to + listOf( + WordFrequency("file", 10), + WordFrequency("word", 5) + ), + "src/Dlc.kt" to + listOf( + WordFrequency("word", 15), + WordFrequency("analyzer", 8) + ) + ) + + // Act + val result = DirectoryWordAggregator.aggregateDirectories(fileWords) + + // Assert + val srcWords = result["src"] + assertEquals(3, srcWords?.size) + assertEquals("word", srcWords?.find { it.text == "word" }?.text) + assertEquals(20, srcWords?.find { it.text == "word" }?.frequency) // 5 + 15 + assertEquals(10, srcWords?.find { it.text == "file" }?.frequency) + assertEquals(8, srcWords?.find { it.text == "analyzer" }?.frequency) + } + + @Test + fun `should aggregate words from nested directories`() { + // Arrange + val fileWords = + mapOf( + "src/main/kotlin/Main.kt" to + listOf( + WordFrequency("file", 10), + WordFrequency("main", 5) + ), + "src/test/kotlin/MainTest.kt" to + listOf( + WordFrequency("test", 8), + WordFrequency("main", 3) + ) + ) + + // Act + val result = DirectoryWordAggregator.aggregateDirectories(fileWords) + + // Assert + // Root should have all words aggregated + val rootWords = result["."] + assertEquals(3, rootWords?.size) + assertEquals(10, rootWords?.find { it.text == "file" }?.frequency) + assertEquals(8, rootWords?.find { it.text == "main" }?.frequency) // 5 + 3 + assertEquals(8, rootWords?.find { it.text == "test" }?.frequency) + + // src should have all words + val srcWords = result["src"] + assertEquals(3, srcWords?.size) + + // src/main should only have main branch words + val mainWords = result["src/main"] + assertEquals(2, mainWords?.size) + assertEquals(10, mainWords?.find { it.text == "file" }?.frequency) + assertEquals(5, mainWords?.find { it.text == "main" }?.frequency) + + // src/test should only have test branch words + val testWords = result["src/test"] + assertEquals(2, testWords?.size) + assertEquals(8, testWords?.find { it.text == "test" }?.frequency) + assertEquals(3, testWords?.find { it.text == "main" }?.frequency) + } + + @Test + fun `should handle empty input`() { + // Arrange + val fileWords = emptyMap>() + + // Act + val result = DirectoryWordAggregator.aggregateDirectories(fileWords) + + // Assert + assertEquals(0, result.size) + } + + @Test + fun `should not sort aggregated words - sorting is done by caller`() { + // Arrange + val fileWords = + mapOf( + "src/Main.kt" to + listOf( + WordFrequency("aaa", 10), + WordFrequency("bbb", 50), + WordFrequency("ccc", 30) + ) + ) + + // Act + val result = DirectoryWordAggregator.aggregateDirectories(fileWords) + + // Assert - aggregator returns unsorted results, caller (SourceAnalyzer) sorts + val srcWords = result["src"] + assertEquals(3, srcWords?.size) + assertEquals(10, srcWords?.find { it.text == "aaa" }?.frequency) + assertEquals(50, srcWords?.find { it.text == "bbb" }?.frequency) + assertEquals(30, srcWords?.find { it.text == "ccc" }?.frequency) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainProjectGeneratorTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainProjectGeneratorTest.kt new file mode 100644 index 0000000000..9e0c48d293 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/output/DomainProjectGeneratorTest.kt @@ -0,0 +1,174 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.output + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import de.maibornwolff.codecharta.model.LensSet +import de.maibornwolff.codecharta.model.NodeId +import de.maibornwolff.codecharta.model.NodeType +import de.maibornwolff.codecharta.model.Project +import de.maibornwolff.codecharta.serialization.ProjectSerializer +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DomainProjectGeneratorTest { + private fun sampleResult(): DomainAnalysisResult = DomainAnalysisResult( + filePaths = listOf("src/main/App.kt", "src/util/Helper.kt", "README.md"), + wordsByPath = + mapOf( + "." to listOf(WordFrequency("domain", 5, 0.42)), + "src" to listOf(WordFrequency("app", 3)), + "src/main" to listOf(WordFrequency("app", 2)), + "src/util" to listOf(WordFrequency("helper", 1)), + "src/main/App.kt" to listOf(WordFrequency("app", 2)), + "src/util/Helper.kt" to listOf(WordFrequency("helper", 1)), + "README.md" to listOf(WordFrequency("readme", 1)) + ) + ) + + private fun serialize(project: Project): JsonObject = JsonParser.parseString(ProjectSerializer.serializeToString(project)).asJsonObject + + private fun collectFileIds(files: JsonArray): Set { + val ids = mutableSetOf() + files.forEach { element -> + val fileObject = element.asJsonObject + ids.add(fileObject.get("id").asString) + fileObject.get("children")?.takeIf { !it.isJsonNull }?.let { ids.addAll(collectFileIds(it.asJsonArray)) } + } + return ids + } + + @Test + fun `should emit cc json 2 0 with a domain lens`() { + // Arrange + val result = sampleResult() + + // Act + val json = serialize(DomainProjectGenerator().generate(result)) + + // Assert + assertEquals("2.0", json.getAsJsonObject("meta").get("apiVersion").asString) + assertTrue(json.getAsJsonObject("lenses").has(LensSet.DOMAIN_KEY)) + } + + @Test + fun `should key the domain lens by node ids that resolve against the files tree`() { + // Arrange + val result = sampleResult() + + // Act + val json = serialize(DomainProjectGenerator().generate(result)) + val fileIds = collectFileIds(json.getAsJsonArray("files")) + val domainKeys = json.getAsJsonObject("lenses").getAsJsonObject(LensSet.DOMAIN_KEY).keySet() + + // Assert - every domain lens key is an id emitted into the files tree + assertTrue(domainKeys.isNotEmpty()) + assertTrue(fileIds.containsAll(domainKeys), "domain keys ${domainKeys - fileIds} are not present as file ids") + } + + @Test + fun `should compute file and folder and root ids with the matching node type`() { + // Arrange + val result = sampleResult() + + // Act + val domainKeys = serialize(DomainProjectGenerator().generate(result)) + .getAsJsonObject("lenses") + .getAsJsonObject(LensSet.DOMAIN_KEY) + .keySet() + + // Assert - leaf keyed as File, directory keyed as Folder, root keyed as empty-segment Folder + assertTrue(domainKeys.contains(NodeId.fromSegments(listOf("src", "main", "App.kt"), NodeType.File))) + assertTrue(domainKeys.contains(NodeId.fromSegments(listOf("src", "main"), NodeType.Folder))) + assertTrue(domainKeys.contains(NodeId.fromSegments(emptyList(), NodeType.Folder))) + } + + @Test + fun `should carry the words for a node into its domain lens entry`() { + // Arrange + val result = sampleResult() + val appId = NodeId.fromSegments(listOf("src", "main", "App.kt"), NodeType.File) + + // Act + val words = serialize(DomainProjectGenerator().generate(result)) + .getAsJsonObject("lenses") + .getAsJsonObject(LensSet.DOMAIN_KEY) + .getAsJsonArray(appId) + + // Assert + val firstWord = words.first().asJsonObject + assertEquals("app", firstWord.get("text").asString) + assertEquals(2, firstWord.get("frequency").asInt) + } + + @Test + fun `should include tfidf when present and omit it when null`() { + // Arrange + val result = sampleResult() + + // Act + val domain = serialize(DomainProjectGenerator().generate(result)).getAsJsonObject("lenses").getAsJsonObject(LensSet.DOMAIN_KEY) + val rootWord = domain.getAsJsonArray(NodeId.fromSegments(emptyList(), NodeType.Folder)).first().asJsonObject + val appWord = + domain.getAsJsonArray(NodeId.fromSegments(listOf("src", "main", "App.kt"), NodeType.File)).first().asJsonObject + + // Assert + assertTrue(rootWord.has("tfidf")) + assertEquals(0.42, rootWord.get("tfidf").asDouble) + assertFalse(appWord.has("tfidf")) + } + + @Test + fun `should produce identical output across runs for the same input`() { + // Arrange + val result = sampleResult() + + // Act + val first = ProjectSerializer.serializeToString(DomainProjectGenerator().generate(result)) + val second = ProjectSerializer.serializeToString(DomainProjectGenerator().generate(result)) + + // Assert - byte-stable output (and therefore a reproducible checksum) + assertEquals(first, second) + } + + @Test + fun `should align domain keys with the files tree for backslash separated file paths`() { + // Arrange - a Windows-style file path (backslash separators) with forward-slashed directory keys + val result = + DomainAnalysisResult( + filePaths = listOf("src\\main\\App.kt"), + wordsByPath = + mapOf( + "." to listOf(WordFrequency("domain", 1)), + "src" to listOf(WordFrequency("app", 1)), + "src/main" to listOf(WordFrequency("app", 1)), + "src\\main\\App.kt" to listOf(WordFrequency("app", 1)) + ) + ) + + // Act + val json = serialize(DomainProjectGenerator().generate(result)) + val fileIds = collectFileIds(json.getAsJsonArray("files")) + val domainKeys = json.getAsJsonObject("lenses").getAsJsonObject(LensSet.DOMAIN_KEY).keySet() + + // Assert - the backslash file path builds a nested tree and every domain key resolves against it + assertTrue(fileIds.contains(NodeId.fromSegments(listOf("src", "main", "App.kt"), NodeType.File))) + assertTrue(fileIds.contains(NodeId.fromSegments(listOf("src", "main"), NodeType.Folder))) + assertTrue(fileIds.containsAll(domainKeys), "domain keys ${domainKeys - fileIds} are not present as file ids") + } + + @Test + fun `should emit an empty lens and a root only tree for an empty analysis`() { + // Arrange + val result = DomainAnalysisResult(filePaths = emptyList(), wordsByPath = emptyMap()) + + // Act + val json = serialize(DomainProjectGenerator().generate(result)) + + // Assert - no domain entries, and the tree carries only the root folder (no leaves) + assertTrue(json.getAsJsonObject("lenses").getAsJsonObject(LensSet.DOMAIN_KEY).keySet().isEmpty()) + assertEquals(setOf(NodeId.fromSegments(emptyList(), NodeType.Folder)), collectFileIds(json.getAsJsonArray("files"))) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/CoroutineFileProcessorTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/CoroutineFileProcessorTest.kt new file mode 100644 index 0000000000..ba64502623 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/CoroutineFileProcessorTest.kt @@ -0,0 +1,171 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CoroutineFileProcessorTest { + @Test + fun `should keep processing other files when one file throws during processing`() { + // Arrange + val processor = CoroutineFileProcessor() + val files = listOf(File("ok1.kt"), File("broken.kt"), File("ok2.kt")) + val basePath = "." + val contentReader: (File) -> String = { "content" } + val wordExtractor: (File, String) -> FileResult = { file, _ -> + if (file.name == "broken.kt") { + throw RuntimeException("parse failure") + } + FileResult.Processed(mapOf("word" to 1)) + } + + // Act + val result = processor.processFilesIndividually(files, basePath, contentReader, wordExtractor) + + // Assert + assertEquals(2, result.perFileWordCounts.size) + assertEquals(listOf("broken.kt"), result.failedFiles) + } + + @Test + fun `should record files that throw while reading content as failed`() { + // Arrange + val processor = CoroutineFileProcessor() + val files = listOf(File("readable.kt"), File("unreadable.kt")) + val basePath = "." + val contentReader: (File) -> String = { file -> + if (file.name == "unreadable.kt") throw RuntimeException("read failure") else "content" + } + val wordExtractor: (File, String) -> FileResult = { _, _ -> FileResult.Processed(mapOf("word" to 1)) } + + // Act + val result = processor.processFilesIndividually(files, basePath, contentReader, wordExtractor) + + // Assert + assertEquals(1, result.perFileWordCounts.size) + assertEquals(listOf("unreadable.kt"), result.failedFiles) + } + + @Test + fun `should advance progress callback even for failed files`() { + // Arrange + val processor = CoroutineFileProcessor() + val files = listOf(File("ok.kt"), File("broken.kt")) + var callbackCount = 0 + val contentReader: (File) -> String = { "content" } + val wordExtractor: (File, String) -> FileResult = { file, _ -> + if (file.name == "broken.kt") throw RuntimeException("boom") else FileResult.Processed(emptyMap()) + } + + // Act + processor.processFilesIndividually(files, ".", contentReader, wordExtractor) { + callbackCount++ + } + + // Assert + assertEquals(2, callbackCount) + } + + @Test + fun `should report no failed files when all files process successfully`() { + // Arrange + val processor = CoroutineFileProcessor() + val files = listOf(File("a.kt"), File("b.kt")) + val contentReader: (File) -> String = { "content" } + val wordExtractor: (File, String) -> FileResult = { _, _ -> FileResult.Processed(mapOf("word" to 1)) } + + // Act + val result = processor.processFilesIndividually(files, ".", contentReader, wordExtractor) + + // Assert + assertTrue(result.failedFiles.isEmpty()) + } + + @Test + fun `should track skipped files in processFilesIndividually`() { + // Arrange + val processor = CoroutineFileProcessor() + val files = listOf(File("test.kt"), File("readme.md"), File("config.yml")) + val basePath = "." + val contentReader: (File) -> String = { "content" } + val wordExtractor: (File, String) -> FileResult = { file, _ -> + when (file.extension) { + "kt" -> FileResult.Processed(mapOf("kotlin" to 1)) + else -> FileResult.Skipped(file.extension) + } + } + + // Act + val result = processor.processFilesIndividually(files, basePath, contentReader, wordExtractor) + + // Assert + assertEquals(1, result.perFileWordCounts.size) + assertEquals(2, result.skippedExtensions.size) + assertEquals(1, result.skippedExtensions["md"]) + assertEquals(1, result.skippedExtensions["yml"]) + } + + @Test + fun `should process empty file list individually`() { + // Arrange + val processor = CoroutineFileProcessor() + val files = emptyList() + val contentReader: (File) -> String = { "" } + val wordExtractor: (File, String) -> FileResult = { _, _ -> FileResult.Processed(emptyMap()) } + + // Act + val result = processor.processFilesIndividually(files, ".", contentReader, wordExtractor) + + // Assert + assertEquals(0, result.perFileWordCounts.size) + assertEquals(0, result.skippedExtensions.size) + } + + @Test + fun `should call onFileProcessed callback for each file`() { + // Arrange + val processor = CoroutineFileProcessor() + val files = listOf(File("test1.kt"), File("test2.kt"), File("test3.kt")) + val basePath = "." + var callbackCount = 0 + val contentReader: (File) -> String = { "content" } + val wordExtractor: (File, String) -> FileResult = { _, _ -> + FileResult.Processed(mapOf("word" to 1)) + } + + // Act + processor.processFilesIndividually(files, basePath, contentReader, wordExtractor) { + callbackCount++ + } + + // Assert + assertEquals(3, callbackCount) + } + + @Test + fun `should store word counts per file path`() { + // Arrange + val processor = CoroutineFileProcessor() + val files = listOf(File("/base/test1.kt"), File("/base/test2.kt")) + val basePath = "/base" + val contentReader: (File) -> String = { file -> + when (file.name) { + "test1.kt" -> "hello world" + "test2.kt" -> "hello kotlin" + else -> "" + } + } + val wordExtractor: (File, String) -> FileResult = { _, content -> + FileResult.Processed(content.split(" ").groupingBy { it }.eachCount()) + } + + // Act + val result = processor.processFilesIndividually(files, basePath, contentReader, wordExtractor) + + // Assert + assertEquals(2, result.perFileWordCounts.size) + assertEquals(mapOf("hello" to 1, "world" to 1), result.perFileWordCounts["test1.kt"]) + assertEquals(mapOf("hello" to 1, "kotlin" to 1), result.perFileWordCounts["test2.kt"]) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/ExtractionWeightsTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/ExtractionWeightsTest.kt new file mode 100644 index 0000000000..4dc3fd3bfa --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/ExtractionWeightsTest.kt @@ -0,0 +1,126 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ExtractionWeightsTest { + @Test + fun `should create ExtractionWeights with default values`() { + // Arrange & Act + val weights = ExtractionWeights() + + // Assert + assertEquals(3, weights.identifierWeight) + assertEquals(2, weights.commentWeight) + assertEquals(1, weights.stringWeight) + } + + @Test + fun `should create ExtractionWeights with custom values`() { + // Arrange & Act + val weights = + ExtractionWeights( + identifierWeight = 5, + commentWeight = 3, + stringWeight = 2 + ) + + // Assert + assertEquals(5, weights.identifierWeight) + assertEquals(3, weights.commentWeight) + assertEquals(2, weights.stringWeight) + } + + @Test + fun `should fail when identifier weight is zero`() { + // Arrange & Act & Assert + val exception = + assertFailsWith { + ExtractionWeights(identifierWeight = 0) + } + assertEquals("Identifier weight must be positive, got 0", exception.message) + } + + @Test + fun `should fail when identifier weight is negative`() { + // Arrange & Act & Assert + val exception = + assertFailsWith { + ExtractionWeights(identifierWeight = -1) + } + assertEquals("Identifier weight must be positive, got -1", exception.message) + } + + @Test + fun `should fail when comment weight is zero`() { + // Arrange & Act & Assert + val exception = + assertFailsWith { + ExtractionWeights(commentWeight = 0) + } + assertEquals("Comment weight must be positive, got 0", exception.message) + } + + @Test + fun `should fail when comment weight is negative`() { + // Arrange & Act & Assert + val exception = + assertFailsWith { + ExtractionWeights(commentWeight = -1) + } + assertEquals("Comment weight must be positive, got -1", exception.message) + } + + @Test + fun `should fail when string weight is zero`() { + // Arrange & Act & Assert + val exception = + assertFailsWith { + ExtractionWeights(stringWeight = 0) + } + assertEquals("String weight must be positive, got 0", exception.message) + } + + @Test + fun `should fail when string weight is negative`() { + // Arrange & Act & Assert + val exception = + assertFailsWith { + ExtractionWeights(stringWeight = -1) + } + assertEquals("String weight must be positive, got -1", exception.message) + } + + @Test + fun `should create ExtractionWeights with minimum valid values`() { + // Arrange & Act + val weights = + ExtractionWeights( + identifierWeight = 1, + commentWeight = 1, + stringWeight = 1 + ) + + // Assert + assertEquals(1, weights.identifierWeight) + assertEquals(1, weights.commentWeight) + assertEquals(1, weights.stringWeight) + } + + @Test + fun `should create ExtractionWeights with large values`() { + // Arrange & Act + val weights = + ExtractionWeights( + identifierWeight = 100, + commentWeight = 50, + stringWeight = 25 + ) + + // Assert + assertEquals(100, weights.identifierWeight) + assertEquals(50, weights.commentWeight) + assertEquals(25, weights.stringWeight) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileAnalyzerTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileAnalyzerTest.kt new file mode 100644 index 0000000000..e70d8c3821 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FileAnalyzerTest.kt @@ -0,0 +1,283 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class FileAnalyzerTest { + private fun FileResult.wordsOrEmpty(): Map = when (this) { + is FileResult.Processed -> words + is FileResult.Skipped -> emptyMap() + } + + @Test + fun `should extract words from Kotlin file using tree-sitter`() { + // Arrange + val tempDir = createTempDirectory().toFile() + val file = File(tempDir, "Customer.kt") + file.writeText( + """ + // Process customer data + class CustomerService { + fun processCustomer() { + } + } + """.trimIndent() + ) + + val stopWordFilter = StopWordFilter(emptyList()) + val analyzer = FileAnalyzer(stopWordFilter) + + // Act + val result = analyzer.extractWordsFromFile(file, file.readText()) + + // Assert + assertIs(result) + val wordCounts = result.words + assertTrue(wordCounts.containsKey("customer")) + assertTrue(wordCounts.containsKey("service")) + assertTrue(wordCounts.containsKey("process")) + + // Cleanup + tempDir.deleteRecursively() + } + + @Test + fun `should filter language keywords when provided`() { + // Arrange + val tempDir = createTempDirectory().toFile() + val file = File(tempDir, "code.kt") + file.writeText( + """ + class CustomerService { + fun process() {} + } + """.trimIndent() + ) + + val stopWordFilter = StopWordFilter(listOf(ResourceKeywords("keywords/kotlin-keywords.txt"))) + val analyzer = FileAnalyzer(stopWordFilter) + + // Act + val result = analyzer.extractWordsFromFile(file, file.readText()) + + // Assert + assertIs(result) + val wordCounts = result.words + assertTrue(wordCounts.containsKey("customer")) + assertTrue(wordCounts.containsKey("service")) + assertTrue(wordCounts.containsKey("process")) + // class and fun are Kotlin keywords and should be filtered + assertTrue(!wordCounts.containsKey("class")) + assertTrue(!wordCounts.containsKey("fun")) + + // Cleanup + tempDir.deleteRecursively() + } + + @Test + fun `should use custom extraction weights when provided`() { + // Arrange + val tempDir = createTempDirectory().toFile() + val file = File(tempDir, "Product.kt") + file.writeText( + """ + // Product manager + class ProductService { + } + """.trimIndent() + ) + + val stopWordFilter = StopWordFilter(emptyList()) + val weights = ExtractionWeights(identifierWeight = 5, commentWeight = 3, stringWeight = 1) + val analyzer = FileAnalyzer(stopWordFilter, weights) + + // Act + val result = analyzer.extractWordsFromFile(file, file.readText()) + + // Assert + assertIs(result) + val wordCounts = result.words + // "product" should have higher count from identifier (5) vs comment (3) + val productCount = wordCounts["product"] ?: 0 + assertTrue(productCount >= 5) // At least from class name with weight 5 + + // Cleanup + tempDir.deleteRecursively() + } + + @Test + fun `should return empty map when file has no extractable words`() { + // Arrange + val tempDir = createTempDirectory().toFile() + val file = File(tempDir, "empty.kt") + file.writeText("") + + val stopWordFilter = StopWordFilter(emptyList()) + val analyzer = FileAnalyzer(stopWordFilter) + + // Act + val result = analyzer.extractWordsFromFile(file, file.readText()) + + // Assert + assertIs(result) + assertTrue(result.words.isEmpty()) + + // Cleanup + tempDir.deleteRecursively() + } + + @Test + fun `should return Skipped result for unsupported file extension`() { + // Arrange + val tempDir = createTempDirectory().toFile() + val file = File(tempDir, "readme.txt") + file.writeText("hello world kotlin") + + val stopWordFilter = StopWordFilter(emptyList()) + val analyzer = FileAnalyzer(stopWordFilter) + + // Act + val result = analyzer.extractWordsFromFile(file, file.readText()) + + // Assert - unsupported extensions return Skipped + assertIs(result) + assertEquals("txt", result.extension) + + // Cleanup + tempDir.deleteRecursively() + } + + @Test + fun `should extract words from TypeScript file`() { + // Arrange + val tempDir = createTempDirectory().toFile() + val file = File(tempDir, "user.service.ts") + file.writeText( + """ + // User management service + class UserService { + async fetchUser(userId: string): Promise { + return this.http.get(`/users/${'$'}{userId}`); + } + } + """.trimIndent() + ) + + val stopWordFilter = StopWordFilter(emptyList()) + val analyzer = FileAnalyzer(stopWordFilter) + + // Act + val result = analyzer.extractWordsFromFile(file, file.readText()) + + // Assert + assertIs(result) + val wordCounts = result.words + assertTrue(wordCounts.containsKey("user")) + assertTrue(wordCounts.containsKey("service")) + assertTrue(wordCounts.containsKey("fetch")) + + // Cleanup + tempDir.deleteRecursively() + } + + @Test + fun `should extract words from JavaScript file`() { + // Arrange + val tempDir = createTempDirectory().toFile() + val file = File(tempDir, "payment.js") + file.writeText( + """ + // Payment processing module + function processPayment(amount) { + return validateAmount(amount); + } + """.trimIndent() + ) + + val stopWordFilter = StopWordFilter(emptyList()) + val analyzer = FileAnalyzer(stopWordFilter) + + // Act + val result = analyzer.extractWordsFromFile(file, file.readText()) + + // Assert - should extract some words from JavaScript file + assertIs(result) + val wordCounts = result.words + assertTrue(wordCounts.isNotEmpty(), "Should extract words from JavaScript") + assertTrue(wordCounts.containsKey("payment"), "payment should be extracted from function name") + assertTrue(wordCounts.containsKey("process"), "process should be extracted from function name") + + // Cleanup + tempDir.deleteRecursively() + } + + @Test + fun `should handle concurrent access without data corruption`() { + // Arrange + val tempDir = createTempDirectory().toFile() + val stopWordFilter = StopWordFilter(emptyList()) + val analyzer = FileAnalyzer(stopWordFilter) + + // Create files of different languages to exercise pipeline caching + val kotlinFile = + File(tempDir, "Service.kt").apply { + writeText("class CustomerService { fun process() {} }") + } + val tsFile = + File(tempDir, "service.ts").apply { + writeText("class OrderService { process(): void {} }") + } + val jsFile = + File(tempDir, "handler.js").apply { + writeText("function handlePayment() { return true; }") + } + + val files = listOf(kotlinFile, tsFile, jsFile) + + // Act - process all files concurrently multiple times + val iterations = 100 + runBlocking(Dispatchers.Default) { + (1..iterations) + .map { + async { + files.forEach { file -> + analyzer.extractWordsFromFile(file, file.readText()) + } + } + }.awaitAll() + } + + // Assert - verify results are consistent (no corruption) + val kotlinResult = analyzer.extractWordsFromFile(kotlinFile, kotlinFile.readText()).wordsOrEmpty() + val tsResult = analyzer.extractWordsFromFile(tsFile, tsFile.readText()).wordsOrEmpty() + val jsResult = analyzer.extractWordsFromFile(jsFile, jsFile.readText()).wordsOrEmpty() + + assertTrue(kotlinResult.containsKey("customer")) + assertTrue(kotlinResult.containsKey("service")) + assertTrue(tsResult.containsKey("order")) + assertTrue(tsResult.containsKey("service")) + assertTrue(jsResult.containsKey("handle")) + assertTrue(jsResult.containsKey("payment")) + + // Verify pipeline count matches language count (not corrupted by race conditions) + assertEquals( + 3, + Language.entries + .filter { lang -> + files.any { Language.fromExtension(it.extension) == lang } + }.size + ) + + // Cleanup + tempDir.deleteRecursively() + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FrameworkDetectorTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FrameworkDetectorTest.kt new file mode 100644 index 0000000000..fb98364242 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/FrameworkDetectorTest.kt @@ -0,0 +1,446 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FrameworkDetectorTest { + @Test + fun `should return empty map when no project files exist`( + @TempDir tempDir: Path + ) { + // Arrange + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertTrue(frameworksByPath.isEmpty()) + } + + @Test + fun `should map directory to detected framework from package json`( + @TempDir tempDir: Path + ) { + // Arrange + val frontendDir = Files.createDirectory(tempDir.resolve("frontend")) + val packageJson = frontendDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "angular-project", + "dependencies": { + "@angular/core": "^17.0.0" + } + } + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(1, frameworksByPath.size) + assertEquals(setOf(Framework.ANGULAR), frameworksByPath[frontendDir]) + } + + @Test + fun `should map multiple directories to their respective frameworks`( + @TempDir tempDir: Path + ) { + // Arrange + val angularDir = Files.createDirectory(tempDir.resolve("angular-app")) + val reactDir = Files.createDirectory(tempDir.resolve("react-app")) + + angularDir.resolve("package.json").writeText( + """{"dependencies": {"@angular/core": "^17.0.0"}}""" + ) + reactDir.resolve("package.json").writeText( + """{"dependencies": {"react": "^18.0.0"}}""" + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(2, frameworksByPath.size) + assertEquals(setOf(Framework.ANGULAR), frameworksByPath[angularDir]) + assertEquals(setOf(Framework.REACT), frameworksByPath[reactDir]) + } + + @Test + fun `should return empty map when package json has no dependencies`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText("""{"name": "test-project"}""") + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertTrue(frameworksByPath.isEmpty()) + } + + @Test + fun `should detect React in dependencies`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "react-project", + "dependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + } + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(setOf(Framework.REACT), frameworksByPath[tempDir]) + } + + @Test + fun `should detect Angular in dependencies`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "angular-project", + "dependencies": { + "@angular/core": "^17.0.0", + "@angular/common": "^17.0.0" + } + } + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(setOf(Framework.ANGULAR), frameworksByPath[tempDir]) + } + + @Test + fun `should detect both React and Angular in same directory`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "multi-framework-project", + "dependencies": { + "react": "^18.0.0", + "@angular/core": "^17.0.0" + } + } + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(setOf(Framework.REACT, Framework.ANGULAR), frameworksByPath[tempDir]) + } + + @Test + fun `should detect React in devDependencies`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "react-dev-project", + "devDependencies": { + "react": "^18.0.0" + } + } + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(setOf(Framework.REACT), frameworksByPath[tempDir]) + } + + @Test + fun `should detect Angular in devDependencies`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "angular-dev-project", + "devDependencies": { + "@angular/core": "^17.0.0" + } + } + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(setOf(Framework.ANGULAR), frameworksByPath[tempDir]) + } + + @Test + fun `should handle invalid JSON gracefully`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText("invalid json content {{{") + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertTrue(frameworksByPath.isEmpty()) + } + + @Test + fun `should return empty map when no framework is detected`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "other-project", + "dependencies": { + "express": "^4.18.0", + "lodash": "^4.17.21" + } + } + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertTrue(frameworksByPath.isEmpty()) + } + + @Test + fun `should detect ASP NET from csproj file`( + @TempDir tempDir: Path + ) { + // Arrange + val csprojFile = tempDir.resolve("TestProject.csproj") + csprojFile.writeText( + """ + + + net8.0 + + + + + + + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(setOf(Framework.ASPNET), frameworksByPath[tempDir]) + } + + @Test + fun `should detect Entity Framework from csproj file`( + @TempDir tempDir: Path + ) { + // Arrange + val csprojFile = tempDir.resolve("TestProject.csproj") + csprojFile.writeText( + """ + + + net8.0 + + + + + + + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(setOf(Framework.ENTITYFRAMEWORK), frameworksByPath[tempDir]) + } + + @Test + fun `should detect both ASP NET and Entity Framework from csproj file`( + @TempDir tempDir: Path + ) { + // Arrange + val csprojFile = tempDir.resolve("TestProject.csproj") + csprojFile.writeText( + """ + + + net8.0 + + + + + + + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(setOf(Framework.ASPNET, Framework.ENTITYFRAMEWORK), frameworksByPath[tempDir]) + } + + @Test + fun `should merge frameworks from multiple csproj files in same directory`( + @TempDir tempDir: Path + ) { + // Arrange + val csprojFile1 = tempDir.resolve("WebProject.csproj") + csprojFile1.writeText( + """ + + + + + + """.trimIndent() + ) + val csprojFile2 = tempDir.resolve("DataProject.csproj") + csprojFile2.writeText( + """ + + + + + + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertEquals(setOf(Framework.ASPNET, Framework.ENTITYFRAMEWORK), frameworksByPath[tempDir]) + } + + @Test + fun `should return empty map when csproj file has no recognized packages`( + @TempDir tempDir: Path + ) { + // Arrange + val csprojFile = tempDir.resolve("TestProject.csproj") + csprojFile.writeText( + """ + + + net8.0 + + + + + + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + assertTrue(frameworksByPath.isEmpty()) + } + + @Test + fun `should map both package json and csproj to same directory when colocated`( + @TempDir tempDir: Path + ) { + // Arrange + val packageJson = tempDir.resolve("package.json") + packageJson.writeText( + """ + { + "name": "fullstack-project", + "dependencies": { + "react": "^18.0.0" + } + } + """.trimIndent() + ) + val csprojFile = tempDir.resolve("Backend.csproj") + csprojFile.writeText( + """ + + + + + + """.trimIndent() + ) + val detector = FrameworkDetector() + + // Act + val frameworksByPath = detector.detectFrameworks(tempDir) + + // Assert + // Both frameworks should be mapped to the same tempDir + assertEquals(1, frameworksByPath.size) + assertTrue(frameworksByPath[tempDir]?.contains(Framework.REACT) == true) + assertTrue(frameworksByPath[tempDir]?.contains(Framework.ASPNET) == true) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/LanguageTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/LanguageTest.kt new file mode 100644 index 0000000000..366b1dc3c9 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/LanguageTest.kt @@ -0,0 +1,448 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LanguageTest { + @Test + fun `should return KOTLIN for kt extension`() { + // Arrange & Act + val language = Language.fromExtension("kt") + + // Assert + assertEquals(Language.KOTLIN, language) + } + + @Test + fun `should return KOTLIN for kts extension`() { + // Arrange & Act + val language = Language.fromExtension("kts") + + // Assert + assertEquals(Language.KOTLIN, language) + } + + @Test + fun `should return TYPESCRIPT for ts and tsx extensions`() { + // Arrange & Act + val tsLanguage = Language.fromExtension("ts") + val tsxLanguage = Language.fromExtension("tsx") + + // Assert + assertEquals(Language.TYPESCRIPT, tsLanguage) + assertEquals(Language.TYPESCRIPT, tsxLanguage) + } + + @Test + fun `should return JAVASCRIPT for js jsx mjs cjs extensions`() { + // Arrange & Act + val jsLanguage = Language.fromExtension("js") + val jsxLanguage = Language.fromExtension("jsx") + val mjsLanguage = Language.fromExtension("mjs") + val cjsLanguage = Language.fromExtension("cjs") + + // Assert + assertEquals(Language.JAVASCRIPT, jsLanguage) + assertEquals(Language.JAVASCRIPT, jsxLanguage) + assertEquals(Language.JAVASCRIPT, mjsLanguage) + assertEquals(Language.JAVASCRIPT, cjsLanguage) + } + + @Test + fun `should return JAVA for java extension`() { + // Arrange & Act + val language = Language.fromExtension("java") + + // Assert + assertEquals(Language.JAVA, language) + } + + @Test + fun `should return PYTHON for py and pyw extensions`() { + // Arrange & Act + val pyLanguage = Language.fromExtension("py") + val pywLanguage = Language.fromExtension("pyw") + + // Assert + assertEquals(Language.PYTHON, pyLanguage) + assertEquals(Language.PYTHON, pywLanguage) + } + + @Test + fun `should return CSHARP for cs extension`() { + // Arrange & Act + val language = Language.fromExtension("cs") + + // Assert + assertEquals(Language.CSHARP, language) + } + + @Test + fun `should return null for unsupported extensions`() { + // Arrange & Act + val exLanguage = Language.fromExtension("ex") + val scalaLanguage = Language.fromExtension("scala") + + // Assert + assertNull(exLanguage, "Elixir is not supported") + assertNull(scalaLanguage, "Scala is not supported") + } + + @Test + fun `should return RUST for rs extension`() { + // Arrange & Act + val language = Language.fromExtension("rs") + + // Assert + assertEquals(Language.RUST, language) + } + + @Test + fun `should have ResourceKeywords for RUST with Rust keywords`() { + // Arrange & Act + val keywords = Language.RUST.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("fn")) + assertTrue(keywords.getKeywords().contains("let")) + assertTrue(keywords.getKeywords().contains("struct")) + assertTrue(keywords.getKeywords().contains("impl")) + } + + @Test + fun `should handle uppercase extensions`() { + // Arrange & Act + val language = Language.fromExtension("KT") + + // Assert + assertEquals(Language.KOTLIN, language) + } + + @Test + fun `should have ResourceKeywords for KOTLIN with Kotlin keywords`() { + // Arrange & Act + val keywords = Language.KOTLIN.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("class")) + assertTrue(keywords.getKeywords().contains("fun")) + assertTrue(keywords.getKeywords().contains("val")) + } + + @Test + fun `should have ResourceKeywords for JAVA with Java keywords`() { + // Arrange & Act + val keywords = Language.JAVA.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("class")) + assertTrue(keywords.getKeywords().contains("public")) + assertTrue(keywords.getKeywords().contains("interface")) + } + + @Test + fun `should have ResourceKeywords for PYTHON with Python keywords`() { + // Arrange & Act + val keywords = Language.PYTHON.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("def")) + assertTrue(keywords.getKeywords().contains("class")) + assertTrue(keywords.getKeywords().contains("import")) + } + + @Test + fun `should have ResourceKeywords for CSHARP with C# keywords`() { + // Arrange & Act + val keywords = Language.CSHARP.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("class")) + assertTrue(keywords.getKeywords().contains("namespace")) + assertTrue(keywords.getKeywords().contains("public")) + } + + @Test + fun `should return GO for go extension`() { + // Arrange & Act + val language = Language.fromExtension("go") + + // Assert + assertEquals(Language.GO, language) + } + + @Test + fun `should have ResourceKeywords for GO with Go keywords`() { + // Arrange & Act + val keywords = Language.GO.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("func")) + assertTrue(keywords.getKeywords().contains("package")) + } + + @Test + fun `should return C for c and h extensions`() { + // Arrange & Act + val cLanguage = Language.fromExtension("c") + val hLanguage = Language.fromExtension("h") + + // Assert + assertEquals(Language.C, cLanguage) + assertEquals(Language.C, hLanguage) + } + + @Test + fun `should have ResourceKeywords for C with C keywords`() { + // Arrange & Act + val keywords = Language.C.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("int")) + assertTrue(keywords.getKeywords().contains("void")) + } + + @Test + fun `should return CPP for cpp cc cxx hpp hxx h++ extensions`() { + // Arrange & Act + val cppLanguage = Language.fromExtension("cpp") + val ccLanguage = Language.fromExtension("cc") + val cxxLanguage = Language.fromExtension("cxx") + val hppLanguage = Language.fromExtension("hpp") + val hxxLanguage = Language.fromExtension("hxx") + val hPlusPlusLanguage = Language.fromExtension("h++") + + // Assert + assertEquals(Language.CPP, cppLanguage) + assertEquals(Language.CPP, ccLanguage) + assertEquals(Language.CPP, cxxLanguage) + assertEquals(Language.CPP, hppLanguage) + assertEquals(Language.CPP, hxxLanguage) + assertEquals(Language.CPP, hPlusPlusLanguage) + } + + @Test + fun `should have ResourceKeywords for CPP with C++ keywords`() { + // Arrange & Act + val keywords = Language.CPP.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("class")) + assertTrue(keywords.getKeywords().contains("template")) + } + + @Test + fun `should return PHP for php and phtml extensions`() { + // Arrange & Act + val phpLanguage = Language.fromExtension("php") + val phtmlLanguage = Language.fromExtension("phtml") + + // Assert + assertEquals(Language.PHP, phpLanguage) + assertEquals(Language.PHP, phtmlLanguage) + } + + @Test + fun `should have ResourceKeywords for PHP with PHP keywords`() { + // Arrange & Act + val keywords = Language.PHP.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("function")) + assertTrue(keywords.getKeywords().contains("class")) + } + + @Test + fun `should return RUBY for rb rake gemspec extensions`() { + // Arrange & Act + val rbLanguage = Language.fromExtension("rb") + val rakeLanguage = Language.fromExtension("rake") + val gemspecLanguage = Language.fromExtension("gemspec") + + // Assert + assertEquals(Language.RUBY, rbLanguage) + assertEquals(Language.RUBY, rakeLanguage) + assertEquals(Language.RUBY, gemspecLanguage) + } + + @Test + fun `should have ResourceKeywords for RUBY with Ruby keywords`() { + // Arrange & Act + val keywords = Language.RUBY.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("def")) + assertTrue(keywords.getKeywords().contains("class")) + } + + @Test + fun `should return SWIFT for swift extension`() { + // Arrange & Act + val language = Language.fromExtension("swift") + + // Assert + assertEquals(Language.SWIFT, language) + } + + @Test + fun `should have ResourceKeywords for SWIFT with Swift keywords`() { + // Arrange & Act + val keywords = Language.SWIFT.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("func")) + assertTrue(keywords.getKeywords().contains("class")) + } + + @Test + fun `should return BASH for sh bash zsh extensions`() { + // Arrange & Act + val shLanguage = Language.fromExtension("sh") + val bashLanguage = Language.fromExtension("bash") + val zshLanguage = Language.fromExtension("zsh") + + // Assert + assertEquals(Language.BASH, shLanguage) + assertEquals(Language.BASH, bashLanguage) + assertEquals(Language.BASH, zshLanguage) + } + + @Test + fun `should have ResourceKeywords for BASH with Bash keywords`() { + // Arrange & Act + val keywords = Language.BASH.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("if")) + assertTrue(keywords.getKeywords().contains("fi")) + } + + @Test + fun `should return OBJC for m and mm extensions`() { + // Arrange & Act + val mLanguage = Language.fromExtension("m") + val mmLanguage = Language.fromExtension("mm") + + // Assert + assertEquals(Language.OBJC, mLanguage) + assertEquals(Language.OBJC, mmLanguage) + } + + @Test + fun `should have ResourceKeywords for OBJC with Objective-C keywords`() { + // Arrange & Act + val keywords = Language.OBJC.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().isNotEmpty()) + } + + @Test + fun `should return VUE for vue extension`() { + // Arrange & Act + val language = Language.fromExtension("vue") + + // Assert + assertEquals(Language.VUE, language) + } + + @Test + fun `should have ResourceKeywords for VUE with Vue keywords`() { + // Arrange & Act + val keywords = Language.VUE.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().isNotEmpty()) + } + + @Test + fun `should have JavaScript keywords for JAVASCRIPT without TypeScript-only keywords`() { + // Arrange & Act + val jsKeywords = Language.JAVASCRIPT.keywordFilter + val tsKeywords = Language.TYPESCRIPT.keywordFilter + + // Assert + assertIs(jsKeywords) + assertIs(tsKeywords) + + // JS should have common keywords + assertTrue(jsKeywords.getKeywords().contains("function")) + assertTrue(jsKeywords.getKeywords().contains("const")) + + // TS should have TypeScript-specific keywords + assertTrue(tsKeywords.getKeywords().contains("interface")) + assertTrue(tsKeywords.getKeywords().contains("type")) + + // JS should NOT have TypeScript-only keywords + assertTrue(!jsKeywords.getKeywords().contains("interface")) + assertTrue(!jsKeywords.getKeywords().contains("type")) + } + + @Test + fun `should return ABL for p cls w extensions`() { + // Arrange & Act + val pLanguage = Language.fromExtension("p") + val clsLanguage = Language.fromExtension("cls") + val wLanguage = Language.fromExtension("w") + + // Assert + assertEquals(Language.ABL, pLanguage) + assertEquals(Language.ABL, clsLanguage) + assertEquals(Language.ABL, wLanguage) + } + + @Test + fun `should handle uppercase ABL extensions`() { + // Arrange & Act + val pLanguage = Language.fromExtension("P") + val clsLanguage = Language.fromExtension("CLS") + val wLanguage = Language.fromExtension("W") + + // Assert + assertEquals(Language.ABL, pLanguage) + assertEquals(Language.ABL, clsLanguage) + assertEquals(Language.ABL, wLanguage) + } + + @Test + fun `should have ResourceKeywords for ABL with Progress framework terms`() { + // Arrange & Act + val keywords = Language.ABL.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("Progress")) + assertTrue(keywords.getKeywords().contains("JsonObject")) + assertTrue(keywords.getKeywords().contains("AppError")) + } + + @Test + fun `should have ABL system handles in keyword list`() { + // Arrange & Act + val keywords = Language.ABL.keywordFilter + + // Assert + assertIs(keywords) + assertTrue(keywords.getKeywords().contains("SESSION")) + assertTrue(keywords.getKeywords().contains("THIS-OBJECT")) + assertTrue(keywords.getKeywords().contains("THIS-PROCEDURE")) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedFrameworkFilteringIntegrationTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedFrameworkFilteringIntegrationTest.kt new file mode 100644 index 0000000000..24c7f14907 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedFrameworkFilteringIntegrationTest.kt @@ -0,0 +1,180 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Integration tests for path-scoped framework keyword filtering. + * + * Verifies that framework keywords (Angular, React, etc.) are only filtered + * from files within the framework's directory scope, not globally. + */ +class PathScopedFrameworkFilteringIntegrationTest { + @Test + fun `should filter Angular keywords only from files under Angular directory`( + @TempDir tempDir: Path + ) { + // Arrange: monorepo structure with Angular frontend and Kotlin backend + // Create Angular frontend directory with package.json + val frontendDir = tempDir.resolve("frontend").createDirectories() + frontendDir.resolve("package.json").writeText( + """{"dependencies": {"@angular/core": "^17.0.0"}}""" + ) + + // Create backend directory (no package.json) + val backendDir = tempDir.resolve("backend").createDirectories() + + // Setup framework detection + val frameworksByPath = mapOf(frontendDir to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val stopWordFilter = + StopWordFilter( + languageKeywords = + listOf( + ResourceKeywords("keywords/kotlin-keywords.txt"), + ResourceKeywords("keywords/typescript-keywords.txt") + ), + pathScopedKeywordProvider = provider + ) + + // Test words - "component" and "selector" are Angular keywords + val testWords = listOf("component", "selector", "user", "profile", "class") + + // Act: Filter for frontend TypeScript file + val frontendPath = frontendDir.resolve("src/app.component.ts") + val filteredFrontend = stopWordFilter.filter(testWords, frontendPath) + + // Act: Filter for backend Kotlin file + val backendPath = backendDir.resolve("src/Main.kt") + val filteredBackend = stopWordFilter.filter(testWords, backendPath) + + // Assert: Frontend should have Angular keywords filtered + assertFalse(filteredFrontend.contains("component"), "Angular keyword 'component' should be filtered from frontend") + assertFalse(filteredFrontend.contains("selector"), "Angular keyword 'selector' should be filtered from frontend") + assertTrue(filteredFrontend.contains("user"), "'user' should NOT be filtered from frontend") + assertTrue(filteredFrontend.contains("profile"), "'profile' should NOT be filtered from frontend") + assertFalse(filteredFrontend.contains("class"), "Kotlin keyword 'class' should be filtered from frontend") + + // Assert: Backend should NOT have Angular keywords filtered + assertTrue(filteredBackend.contains("component"), "Angular keyword 'component' should NOT be filtered from backend") + assertTrue(filteredBackend.contains("selector"), "Angular keyword 'selector' should NOT be filtered from backend") + assertTrue(filteredBackend.contains("user"), "'user' should NOT be filtered from backend") + assertTrue(filteredBackend.contains("profile"), "'profile' should NOT be filtered from backend") + assertFalse(filteredBackend.contains("class"), "Kotlin keyword 'class' should be filtered from backend") + } + + @Test + fun `should filter React keywords only from React directory in monorepo`( + @TempDir tempDir: Path + ) { + // Arrange: monorepo with separate React and Angular apps + val reactDir = tempDir.resolve("apps/react-app").createDirectories() + val angularDir = tempDir.resolve("apps/angular-app").createDirectories() + val sharedDir = tempDir.resolve("shared/utils").createDirectories() + + val frameworksByPath = + mapOf( + reactDir to setOf(Framework.REACT), + angularDir to setOf(Framework.ANGULAR) + ) + val provider = PathScopedKeywordProvider(frameworksByPath) + val stopWordFilter = StopWordFilter(pathScopedKeywordProvider = provider) + + // "usestate" is a React keyword, "directive" is Angular-only (not in React) + val testWords = listOf("usestate", "directive", "user") + + // Act + val filteredReact = stopWordFilter.filter(testWords, reactDir.resolve("src/App.tsx")) + val filteredAngular = stopWordFilter.filter(testWords, angularDir.resolve("src/app.ts")) + val filteredShared = stopWordFilter.filter(testWords, sharedDir.resolve("helpers.ts")) + + // Assert: React directory - only React keywords filtered + assertFalse(filteredReact.contains("usestate"), "React keyword 'usestate' should be filtered in React dir") + assertTrue(filteredReact.contains("directive"), "Angular-only keyword 'directive' should NOT be filtered in React dir") + + // Assert: Angular directory - only Angular keywords filtered + assertTrue(filteredAngular.contains("usestate"), "React keyword 'usestate' should NOT be filtered in Angular dir") + assertFalse(filteredAngular.contains("directive"), "Angular keyword 'directive' should be filtered in Angular dir") + + // Assert: Shared directory - no framework keywords filtered + assertTrue(filteredShared.contains("usestate"), "React keyword should NOT be filtered in shared dir") + assertTrue(filteredShared.contains("directive"), "Angular keyword should NOT be filtered in shared dir") + } + + @Test + fun `should filter ASP NET keywords only from dotnet directory`( + @TempDir tempDir: Path + ) { + // Arrange: fullstack with C# backend + val backendDir = tempDir.resolve("backend").createDirectories() + val frontendDir = tempDir.resolve("frontend").createDirectories() + + val frameworksByPath = + mapOf( + backendDir to setOf(Framework.ASPNET) + ) + val provider = PathScopedKeywordProvider(frameworksByPath) + val stopWordFilter = StopWordFilter(pathScopedKeywordProvider = provider) + + // ASP.NET keywords use title case: "Controller", "Authorize" + val testWords = listOf("Controller", "Authorize", "user", "service") + + // Act + val filteredBackend = stopWordFilter.filter(testWords, backendDir.resolve("Controllers/UserController.cs")) + val filteredFrontend = stopWordFilter.filter(testWords, frontendDir.resolve("src/App.tsx")) + + // Assert: Backend should have ASP.NET keywords filtered + assertFalse(filteredBackend.contains("Controller"), "ASP.NET keyword 'Controller' should be filtered in backend") + assertFalse(filteredBackend.contains("Authorize"), "ASP.NET keyword 'Authorize' should be filtered in backend") + + // Assert: Frontend should NOT have ASP.NET keywords filtered + assertTrue(filteredFrontend.contains("Controller"), "ASP.NET keyword should NOT be filtered in frontend") + assertTrue(filteredFrontend.contains("Authorize"), "ASP.NET keyword should NOT be filtered in frontend") + } + + @Test + fun `should work with FileAnalyzer for end-to-end filtering`( + @TempDir tempDir: Path + ) { + // Arrange + val frontendDir = tempDir.resolve("frontend").createDirectories() + val frameworksByPath = mapOf(frontendDir to setOf(Framework.ANGULAR)) + + val provider = PathScopedKeywordProvider(frameworksByPath) + val stopWordFilter = + StopWordFilter( + languageKeywords = listOf(ResourceKeywords("keywords/typescript-keywords.txt")), + pathScopedKeywordProvider = provider + ) + val fileAnalyzer = FileAnalyzer(stopWordFilter) + + // Create a TypeScript file with Angular-specific identifiers + val tsFile = frontendDir.resolve("app.component.ts").toFile() + tsFile.parentFile.mkdirs() + tsFile.writeText( + """ + // User component for profile management + class UserComponent { + selector = 'app-user'; + userProfile = {}; + } + """.trimIndent() + ) + + // Act + val result = fileAnalyzer.extractWordsFromFile(tsFile, tsFile.readText()) + val words = (result as FileResult.Processed).words + + // Assert: Angular keywords should be filtered, domain words should remain + assertFalse(words.containsKey("component"), "Angular keyword 'component' should be filtered") + assertFalse(words.containsKey("selector"), "Angular keyword 'selector' should be filtered") + assertTrue(words.containsKey("user"), "Domain word 'user' should remain") + assertTrue(words.containsKey("profile"), "Domain word 'profile' should remain") + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedKeywordProviderTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedKeywordProviderTest.kt new file mode 100644 index 0000000000..ecbdcc7b0c --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/PathScopedKeywordProviderTest.kt @@ -0,0 +1,157 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import java.nio.file.Paths +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PathScopedKeywordProviderTest { + @Test + fun `should return empty list when no frameworks detected`() { + // Arrange + val provider = PathScopedKeywordProvider(emptyMap()) + val filePath = Paths.get("/project/src/main.kt") + + // Act + val keywords = provider.getFrameworkKeywordsForFile(filePath) + + // Assert + assertTrue(keywords.isEmpty()) + } + + @Test + fun `should return framework keywords when file is under framework directory`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val filePath = Paths.get("/project/frontend/src/app.component.ts") + + // Act + val keywords = provider.getFrameworkKeywordsForFile(filePath) + + // Assert + assertEquals(1, keywords.size) + assertTrue(keywords[0] is ResourceKeywords) + // Verify Angular keywords are loaded + assertTrue(keywords[0].getKeywords().contains("component")) + } + + @Test + fun `should return empty list when file is not under any framework directory`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val filePath = Paths.get("/project/backend/src/Main.kt") + + // Act + val keywords = provider.getFrameworkKeywordsForFile(filePath) + + // Assert + assertTrue(keywords.isEmpty()) + } + + @Test + fun `should return multiple framework keywords when file is under directory with multiple frameworks`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR, Framework.REACT)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val filePath = Paths.get("/project/frontend/src/component.tsx") + + // Act + val keywords = provider.getFrameworkKeywordsForFile(filePath) + + // Assert + assertEquals(2, keywords.size) + } + + @Test + fun `should return correct framework for each directory in monorepo`() { + // Arrange + val angularPath = Paths.get("/project/apps/angular-app") + val reactPath = Paths.get("/project/apps/react-app") + val frameworksByPath = + mapOf( + angularPath to setOf(Framework.ANGULAR), + reactPath to setOf(Framework.REACT) + ) + val provider = PathScopedKeywordProvider(frameworksByPath) + + // Act + val angularKeywords = + provider.getFrameworkKeywordsForFile( + Paths.get("/project/apps/angular-app/src/main.ts") + ) + val reactKeywords = + provider.getFrameworkKeywordsForFile( + Paths.get("/project/apps/react-app/src/App.tsx") + ) + val backendKeywords = + provider.getFrameworkKeywordsForFile( + Paths.get("/project/backend/src/Main.kt") + ) + + // Assert + assertEquals(1, angularKeywords.size) + assertTrue(angularKeywords[0].getKeywords().contains("component")) // Angular keyword + + assertEquals(1, reactKeywords.size) + assertTrue(reactKeywords[0].getKeywords().contains("state")) // React keyword + + assertTrue(backendKeywords.isEmpty()) + } + + @Test + fun `should match file at root of framework directory`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val filePath = Paths.get("/project/frontend/package.json") + + // Act + val keywords = provider.getFrameworkKeywordsForFile(filePath) + + // Assert + assertEquals(1, keywords.size) + } + + @Test + fun `should match deeply nested files`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val filePath = Paths.get("/project/frontend/src/app/components/shared/button/button.component.ts") + + // Act + val keywords = provider.getFrameworkKeywordsForFile(filePath) + + // Assert + assertEquals(1, keywords.size) + assertTrue(keywords[0].getKeywords().contains("component")) + } + + @Test + fun `should return same keyword instance for multiple files`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val file1 = Paths.get("/project/frontend/src/app.component.ts") + val file2 = Paths.get("/project/frontend/src/main.ts") + val file3 = Paths.get("/project/frontend/src/index.ts") + + // Act + val keywords1 = provider.getFrameworkKeywordsForFile(file1) + val keywords2 = provider.getFrameworkKeywordsForFile(file2) + val keywords3 = provider.getFrameworkKeywordsForFile(file3) + + // Assert - same instance should be returned (referential equality) + assertTrue(keywords1[0] === keywords2[0]) + assertTrue(keywords2[0] === keywords3[0]) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/StopWordFilterTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/StopWordFilterTest.kt new file mode 100644 index 0000000000..3b520923da --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/StopWordFilterTest.kt @@ -0,0 +1,573 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywordLoader +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import java.nio.file.Paths +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StopWordFilterTest { + @Test + fun `should filter out common English stop words`() { + // Arrange + val filter = StopWordFilter() + val words = listOf("the", "hello", "a", "world", "and", "is", "kotlin") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world", "kotlin"), filtered) + } + + @Test + fun `should keep all words when none are stop words`() { + // Arrange + val filter = StopWordFilter() + val words = listOf("hello", "world", "kotlin") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world", "kotlin"), filtered) + } + + @Test + fun `should return empty list when all words are stop words`() { + // Arrange + val filter = StopWordFilter() + val words = listOf("the", "a", "and", "is", "of") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(emptyList(), filtered) + } + + @Test + fun `should filter language keywords when provided`() { + // Arrange + val languageKeywords = ResourceKeywords("keywords/typescript-keywords.txt") + val filter = StopWordFilter(languageKeywords = listOf(languageKeywords)) + val words = listOf("function", "hello", "const", "world", "if", "kotlin") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world", "kotlin"), filtered) + } + + @Test + fun `should filter both stop words and language keywords`() { + // Arrange + val languageKeywords = ResourceKeywords("keywords/typescript-keywords.txt") + val filter = StopWordFilter(languageKeywords = listOf(languageKeywords)) + val words = listOf("the", "function", "hello", "a", "const", "world") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world"), filtered) + } + + @Test + fun `should work with empty language keywords list`() { + // Arrange + val filter = StopWordFilter(languageKeywords = emptyList()) + val words = listOf("the", "function", "hello", "const", "world") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("function", "hello", "const", "world"), filtered) + } + + @Test + fun `should filter multiple language keywords when provided`() { + // Arrange + val tsKeywords = ResourceKeywords("keywords/typescript-keywords.txt") + val ktKeywords = ResourceKeywords("keywords/kotlin-keywords.txt") + val filter = StopWordFilter(languageKeywords = listOf(tsKeywords, ktKeywords)) + val words = listOf("function", "hello", "const", "fun", "world", "class", "kotlin") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world", "kotlin"), filtered) + } + + @Test + fun `should return true when word is excluded`() { + // Arrange + val filter = StopWordFilter() + + // Act & Assert + assertTrue(filter.isExcluded("the")) + assertTrue(filter.isExcluded("and")) + assertTrue(filter.isExcluded("is")) + } + + @Test + fun `should return false when word is not excluded`() { + // Arrange + val filter = StopWordFilter() + + // Act & Assert + assertFalse(filter.isExcluded("hello")) + assertFalse(filter.isExcluded("world")) + assertFalse(filter.isExcluded("kotlin")) + } + + @Test + fun `should return true when word is a language keyword`() { + // Arrange + val languageKeywords = ResourceKeywords("keywords/typescript-keywords.txt") + val filter = StopWordFilter(languageKeywords = listOf(languageKeywords)) + + // Act & Assert + assertTrue(filter.isExcluded("function")) + assertTrue(filter.isExcluded("const")) + assertTrue(filter.isExcluded("if")) + } + + @Test + fun `should return false when word is not a stop word or keyword`() { + // Arrange + val languageKeywords = ResourceKeywords("keywords/typescript-keywords.txt") + val filter = StopWordFilter(languageKeywords = listOf(languageKeywords)) + + // Act & Assert + assertFalse(filter.isExcluded("hello")) + assertFalse(filter.isExcluded("world")) + assertFalse(filter.isExcluded("kotlin")) + } + + @Test + fun `should load stop words from resource file`() { + // Arrange + val loader = ResourceKeywordLoader() + val filter = StopWordFilter(keywordLoader = loader) + + // Act + val filtered = filter.filter(listOf("the", "hello", "and", "world", "is")) + + // Assert + assertEquals(listOf("hello", "world"), filtered) + } + + @Test + fun `should fail when resource file is missing`() { + // Arrange + val loader = ResourceKeywordLoader() + + // Act & Assert + assertFailsWith { + loader.loadFromResource("stopwords/nonexistent.txt") + } + } + + @Test + fun `should filter all stop words from resource file`() { + // Arrange + val filter = StopWordFilter() + val allStopWords = + listOf( + "a", + "an", + "the", + "and", + "or", + "but", + "as", + "at", + "by", + "for", + "from", + "in", + "of", + "on", + "to", + "with", + "he", + "i", + "it", + "its", + "that", + "these", + "they", + "this", + "those", + "we", + "you", + "are", + "be", + "can", + "could", + "has", + "is", + "may", + "might", + "must", + "not", + "shall", + "should", + "was", + "were", + "will", + "would" + ) + + // Act + val filtered = filter.filter(allStopWords + listOf("hello", "world")) + + // Assert + assertEquals(listOf("hello", "world"), filtered) + } + + @Test + fun `should exclude bigram when any component is a stop word`() { + // Arrange + val filter = StopWordFilter() + + // Act & Assert + assertTrue(filter.isExcluded("the user")) + assertTrue(filter.isExcluded("user the")) + assertTrue(filter.isExcluded("and profile")) + } + + @Test + fun `should not exclude bigram when no component is a stop word`() { + // Arrange + val filter = StopWordFilter() + + // Act & Assert + assertFalse(filter.isExcluded("user profile")) + assertFalse(filter.isExcluded("customer order")) + assertFalse(filter.isExcluded("payment gateway")) + } + + @Test + fun `should exclude bigram when component is technical stop word`() { + // Arrange + val technicalStopWords = ResourceKeywords("keywords/technical-moderate.txt") + val filter = StopWordFilter(languageKeywords = listOf(technicalStopWords)) + + // Act & Assert + assertTrue(filter.isExcluded("user handler")) + assertTrue(filter.isExcluded("error service")) + assertTrue(filter.isExcluded("test util")) + } + + @Test + fun `should not exclude bigram with valid domain terms`() { + // Arrange + val technicalStopWords = ResourceKeywords("keywords/technical-moderate.txt") + val filter = StopWordFilter(languageKeywords = listOf(technicalStopWords)) + + // Act & Assert + assertFalse(filter.isExcluded("user profile")) + assertFalse(filter.isExcluded("customer order")) + assertFalse(filter.isExcluded("payment gateway")) + } + + @Test + fun `should filter custom stop words when provided`() { + // Arrange + val customStopWords = setOf("domain", "custom", "project") + val filter = StopWordFilter(customStopWords = customStopWords) + val words = listOf("hello", "domain", "world", "custom", "kotlin", "project") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world", "kotlin"), filtered) + } + + @Test + fun `should merge custom stop words with English stop words`() { + // Arrange + val customStopWords = setOf("custom") + val filter = StopWordFilter(customStopWords = customStopWords) + val words = listOf("the", "custom", "hello", "a", "world") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world"), filtered) + } + + @Test + fun `should merge custom stop words with language keywords`() { + // Arrange + val languageKeywords = listOf(ResourceKeywords("keywords/kotlin-keywords.txt")) + val customStopWords = setOf("custom") + val filter = StopWordFilter(languageKeywords, customStopWords) + val words = listOf("class", "custom", "hello", "fun", "world") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world"), filtered) + } + + @Test + fun `should merge custom stop words with all filtering sources`() { + // Arrange + val languageKeywords = listOf(ResourceKeywords("keywords/kotlin-keywords.txt"), ResourceKeywords("keywords/technical-moderate.txt")) + val customStopWords = setOf("custom", "domain") + val filter = StopWordFilter(languageKeywords, customStopWords) + val words = listOf("the", "class", "test", "custom", "hello", "domain", "world") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world"), filtered) + } + + @Test + fun `should exclude custom stop words in isExcluded check`() { + // Arrange + val customStopWords = setOf("custom", "domain") + val filter = StopWordFilter(customStopWords = customStopWords) + + // Act & Assert + assertTrue(filter.isExcluded("custom")) + assertTrue(filter.isExcluded("domain")) + assertFalse(filter.isExcluded("hello")) + } + + @Test + fun `should exclude compounds with custom stop words`() { + // Arrange + val customStopWords = setOf("custom") + val filter = StopWordFilter(customStopWords = customStopWords) + + // Act & Assert + assertTrue(filter.isExcluded("custom handler")) + assertTrue(filter.isExcluded("user custom")) + assertFalse(filter.isExcluded("user profile")) + } + + @Test + fun `should work with empty custom stop words set`() { + // Arrange + val filter = StopWordFilter(customStopWords = emptySet()) + val words = listOf("the", "hello", "world") + + // Act + val filtered = filter.filter(words) + + // Assert + assertEquals(listOf("hello", "world"), filtered) + } + + @Test + fun `should exclude trigram when any component is a stop word`() { + // Arrange + val filter = StopWordFilter() + + // Act & Assert + assertTrue(filter.isExcluded("the user profile")) + assertTrue(filter.isExcluded("user the profile")) + assertTrue(filter.isExcluded("user profile the")) + assertTrue(filter.isExcluded("and customer order")) + } + + @Test + fun `should not exclude trigram when no component is a stop word`() { + // Arrange + val filter = StopWordFilter() + + // Act & Assert + assertFalse(filter.isExcluded("user profile data")) + assertFalse(filter.isExcluded("customer order processor")) + assertFalse(filter.isExcluded("payment gateway service")) + } + + @Test + fun `should exclude trigram when component is technical stop word`() { + // Arrange + val technicalStopWords = ResourceKeywords("keywords/technical-moderate.txt") + val filter = StopWordFilter(languageKeywords = listOf(technicalStopWords)) + + // Act & Assert + assertTrue(filter.isExcluded("user profile handler")) + assertTrue(filter.isExcluded("error customer service")) + assertTrue(filter.isExcluded("test user util")) + } + + @Test + fun `should not exclude trigram with valid domain terms`() { + // Arrange + val technicalStopWords = ResourceKeywords("keywords/technical-moderate.txt") + val filter = StopWordFilter(languageKeywords = listOf(technicalStopWords)) + + // Act & Assert + assertFalse(filter.isExcluded("customer order payment")) + assertFalse(filter.isExcluded("user profile data")) + assertFalse(filter.isExcluded("payment gateway integration")) + } + + @Test + fun `should exclude 4-gram when any component is a stop word`() { + // Arrange + val filter = StopWordFilter() + + // Act & Assert + assertTrue(filter.isExcluded("the user profile data")) + assertTrue(filter.isExcluded("user the profile data")) + assertTrue(filter.isExcluded("user profile the data")) + assertTrue(filter.isExcluded("user profile data the")) + } + + @Test + fun `should not exclude 4-gram when no component is a stop word`() { + // Arrange + val filter = StopWordFilter() + + // Act & Assert + assertFalse(filter.isExcluded("user profile data manager")) + assertFalse(filter.isExcluded("customer order payment gateway")) + } + + @Test + fun `should exclude 4-gram when component is technical stop word`() { + // Arrange + val technicalStopWords = ResourceKeywords("keywords/technical-moderate.txt") + val filter = StopWordFilter(languageKeywords = listOf(technicalStopWords)) + + // Act & Assert + assertTrue(filter.isExcluded("user profile data handler")) + assertTrue(filter.isExcluded("customer order service manager")) + } + + @Test + fun `should exclude n-grams with custom stop words`() { + // Arrange + val customStopWords = setOf("legacy") + val filter = StopWordFilter(customStopWords = customStopWords) + + // Act & Assert + assertTrue(filter.isExcluded("legacy user profile")) + assertTrue(filter.isExcluded("user legacy profile")) + assertTrue(filter.isExcluded("user profile legacy")) + assertTrue(filter.isExcluded("legacy user profile data")) + assertFalse(filter.isExcluded("user profile data")) + } + + // Path-scoped framework keyword tests + @Test + fun `should exclude framework keyword when file is under framework directory`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val filter = StopWordFilter(pathScopedKeywordProvider = provider) + val filePath = Paths.get("/project/frontend/src/app.component.ts") + + // Act & Assert + // "component" is an Angular keyword + assertTrue(filter.isExcluded("component", filePath)) + } + + @Test + fun `should not exclude framework keyword when file is outside framework directory`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val filter = StopWordFilter(pathScopedKeywordProvider = provider) + val filePath = Paths.get("/project/backend/src/Main.kt") + + // Act & Assert + // "component" should NOT be filtered for files outside Angular directory + assertFalse(filter.isExcluded("component", filePath)) + } + + @Test + fun `should still exclude global keywords regardless of file path`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val languageKeywords = listOf(ResourceKeywords("keywords/kotlin-keywords.txt")) + val filter = StopWordFilter(languageKeywords, pathScopedKeywordProvider = provider) + val backendPath = Paths.get("/project/backend/src/Main.kt") + + // Act & Assert + // "class" is a Kotlin keyword - should be filtered everywhere + assertTrue(filter.isExcluded("class", backendPath)) + // English stop words should also be filtered everywhere + assertTrue(filter.isExcluded("the", backendPath)) + } + + @Test + fun `should filter path-scoped words from list`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val filter = StopWordFilter(pathScopedKeywordProvider = provider) + val words = listOf("component", "user", "selector", "profile") + + // Act + val filteredFrontend = + filter.filter( + words, + Paths.get("/project/frontend/src/app.ts") + ) + val filteredBackend = + filter.filter( + words, + Paths.get("/project/backend/src/Main.kt") + ) + + // Assert + // Frontend: Angular keywords should be filtered + assertEquals(listOf("user", "profile"), filteredFrontend) + // Backend: Angular keywords should NOT be filtered + assertEquals(listOf("component", "user", "selector", "profile"), filteredBackend) + } + + @Test + fun `should combine path-scoped and global filtering`() { + // Arrange + val frontendPath = Paths.get("/project/frontend") + val frameworksByPath = mapOf(frontendPath to setOf(Framework.ANGULAR)) + val provider = PathScopedKeywordProvider(frameworksByPath) + val languageKeywords = listOf(ResourceKeywords("keywords/kotlin-keywords.txt")) + val filter = StopWordFilter(languageKeywords, pathScopedKeywordProvider = provider) + val words = listOf("component", "class", "the", "user", "fun") + + // Act + val filteredFrontend = + filter.filter( + words, + Paths.get("/project/frontend/src/app.ts") + ) + val filteredBackend = + filter.filter( + words, + Paths.get("/project/backend/src/Main.kt") + ) + + // Assert + // Frontend: Angular + Kotlin + English stop words filtered + assertEquals(listOf("user"), filteredFrontend) + // Backend: Only Kotlin + English stop words filtered (not Angular) + assertEquals(listOf("component", "user"), filteredBackend) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/analysis/TfIdfCalculatorTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/analysis/TfIdfCalculatorTest.kt new file mode 100644 index 0000000000..bcee6a26cf --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/analysis/TfIdfCalculatorTest.kt @@ -0,0 +1,142 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.analysis + +import kotlin.math.log10 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TfIdfCalculatorTest { + @Test + fun `should return empty map for empty input`() { + // Arrange + val calculator = TfIdfCalculator() + val perFileFrequencies = emptyMap>() + + // Act + val result = calculator.calculate(perFileFrequencies) + + // Assert + assertEquals(emptyMap(), result) + } + + @Test + fun `should return empty map for single file project`() { + // Arrange - IDF is undefined for single document + val calculator = TfIdfCalculator() + val perFileFrequencies = + mapOf( + "Main.kt" to mapOf("account" to 10, "string" to 5) + ) + + // Act + val result = calculator.calculate(perFileFrequencies) + + // Assert + assertEquals(emptyMap(), result) + } + + @Test + fun `should calculate TF-IDF for term in one file`() { + // Arrange + // "account" appears in 1 out of 2 files with frequency 10 + // IDF = log10(2/1) = 0.301 + // TF-IDF = 10 * 0.301 = 3.01 + val calculator = TfIdfCalculator() + val perFileFrequencies = + mapOf( + "File1.kt" to mapOf("account" to 10), + "File2.kt" to mapOf("string" to 5) + ) + + // Act + val result = calculator.calculate(perFileFrequencies) + + // Assert + val expectedIdf = log10(2.0 / 1.0) + val expectedTfIdf = 10 * expectedIdf + assertEquals(expectedTfIdf, result["account"]!!, 0.001) + } + + @Test + fun `should calculate zero TF-IDF for term in all files`() { + // Arrange - term appears in all files, IDF = log10(2/2) = 0 + val calculator = TfIdfCalculator() + val perFileFrequencies = + mapOf( + "File1.kt" to mapOf("string" to 10), + "File2.kt" to mapOf("string" to 5) + ) + + // Act + val result = calculator.calculate(perFileFrequencies) + + // Assert + assertEquals(0.0, result["string"]!!, 0.001) + } + + @Test + fun `should sum TF across files for same term`() { + // Arrange - "account" appears in 2 out of 3 files + // Total TF = 10 + 8 = 18 + // IDF = log10(3/2) = 0.176 + // TF-IDF = 18 * 0.176 = 3.17 + val calculator = TfIdfCalculator() + val perFileFrequencies = + mapOf( + "File1.kt" to mapOf("account" to 10), + "File2.kt" to mapOf("account" to 8, "string" to 5), + "File3.kt" to mapOf("string" to 3) + ) + + // Act + val result = calculator.calculate(perFileFrequencies) + + // Assert + val expectedIdf = log10(3.0 / 2.0) + val totalTf = 10 + 8 + val expectedTfIdf = totalTf * expectedIdf + assertEquals(expectedTfIdf, result["account"]!!, 0.001) + } + + @Test + fun `should calculate higher TF-IDF for rare terms`() { + // Arrange + // "domain" appears in 1 of 5 files (rare) + // "string" appears in 5 of 5 files (common) + val calculator = TfIdfCalculator() + val perFileFrequencies = + mapOf( + "File1.kt" to mapOf("domain" to 10, "string" to 5), + "File2.kt" to mapOf("string" to 5), + "File3.kt" to mapOf("string" to 5), + "File4.kt" to mapOf("string" to 5), + "File5.kt" to mapOf("string" to 5) + ) + + // Act + val result = calculator.calculate(perFileFrequencies) + + // Assert + assertTrue(result["domain"]!! > result["string"]!!) + assertEquals(0.0, result["string"]!!, 0.001) // In all files, IDF = 0 + } + + @Test + fun `should handle files with empty word counts`() { + // Arrange + val calculator = TfIdfCalculator() + val perFileFrequencies = + mapOf( + "File1.kt" to mapOf("account" to 10), + "File2.kt" to emptyMap() + ) + + // Act + val result = calculator.calculate(perFileFrequencies) + + // Assert + val expectedIdf = log10(2.0 / 1.0) + val expectedTfIdf = 10 * expectedIdf + assertEquals(expectedTfIdf, result["account"]!!, 0.001) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordLoaderTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordLoaderTest.kt new file mode 100644 index 0000000000..4fa4899208 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordLoaderTest.kt @@ -0,0 +1,64 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords + +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ResourceKeywordLoaderTest { + @Test + fun `should load keywords from resource file`() { + // Arrange + val loader = ResourceKeywordLoader() + + // Act + val keywords = loader.loadFromResource("keywords/kotlin-keywords.txt") + + // Assert + assertTrue(keywords.contains("class")) + assertTrue(keywords.contains("fun")) + assertTrue(keywords.contains("val")) + assertTrue(keywords.contains("var")) + } + + @Test + fun `should filter out comments and empty lines`() { + // Arrange + val loader = ResourceKeywordLoader() + + // Act + val keywords = loader.loadFromResource("keywords/kotlin-keywords.txt") + + // Assert + // Should not contain any lines starting with # + keywords.forEach { keyword -> + assertTrue(!keyword.startsWith("#")) + } + } + + @Test + fun `should throw exception when resource file not found`() { + // Arrange + val loader = ResourceKeywordLoader() + + // Act & Assert + assertFailsWith { + loader.loadFromResource("nonexistent/file.txt") + } + } + + @Test + fun `should return set with all unique keywords`() { + // Arrange + val loader = ResourceKeywordLoader() + + // Act + val kotlinKeywords = loader.loadFromResource("keywords/kotlin-keywords.txt") + val tsKeywords = loader.loadFromResource("keywords/typescript-keywords.txt") + val javaKeywords = loader.loadFromResource("keywords/java-keywords.txt") + + // Assert + assertTrue(kotlinKeywords.size > 50) // Kotlin has many keywords + assertTrue(tsKeywords.size > 40) // TypeScript has many keywords + assertTrue(javaKeywords.size > 60) // Java has many keywords + common types + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordsTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordsTest.kt new file mode 100644 index 0000000000..3e41f2000f --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/keywords/ResourceKeywordsTest.kt @@ -0,0 +1,111 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords + +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ResourceKeywordsTest { + @Test + fun `should load keywords from resource file`() { + // Arrange + val keywords = ResourceKeywords("keywords/kotlin-keywords.txt") + + // Act + val keywordSet = keywords.getKeywords() + + // Assert + assertTrue(keywordSet.isNotEmpty()) + assertTrue(keywordSet.contains("class")) + assertTrue(keywordSet.contains("fun")) + assertTrue(keywordSet.contains("val")) + assertTrue(keywordSet.contains("var")) + } + + @Test + fun `should cache keywords on subsequent calls`() { + // Arrange + val keywords = ResourceKeywords("keywords/java-keywords.txt") + + // Act + val firstCall = keywords.getKeywords() + val secondCall = keywords.getKeywords() + + // Assert + assertTrue(firstCall === secondCall, "Keywords should be cached and return same instance") + } + + @Test + fun `should throw exception for non-existent resource`() { + // Arrange + val keywords = ResourceKeywords("keywords/nonexistent.txt") + + // Act & Assert + assertFailsWith { + keywords.getKeywords() + } + } + + @Test + fun `should load typescript keywords`() { + // Arrange + val keywords = ResourceKeywords("keywords/typescript-keywords.txt") + + // Act + val keywordSet = keywords.getKeywords() + + // Assert + assertTrue(keywordSet.contains("interface")) + assertTrue(keywordSet.contains("type")) + assertTrue(keywordSet.contains("async")) + } + + @Test + fun `should load javascript keywords without typescript-only keywords`() { + // Arrange + val jsKeywords = ResourceKeywords("keywords/javascript-keywords.txt") + val tsKeywords = ResourceKeywords("keywords/typescript-keywords.txt") + + // Act + val jsSet = jsKeywords.getKeywords() + val tsSet = tsKeywords.getKeywords() + + // Assert - JS should not contain TypeScript-only keywords + assertTrue(jsSet.contains("function")) + assertTrue(jsSet.contains("const")) + assertTrue(jsSet.contains("let")) + + // TypeScript-only keywords should be in TS but not in JS + val tsOnlyKeywords = listOf("interface", "type", "namespace", "enum", "abstract", "declare", "never", "unknown") + tsOnlyKeywords.forEach { keyword -> + assertTrue(tsSet.contains(keyword), "TypeScript should contain '$keyword'") + assertTrue(!jsSet.contains(keyword), "JavaScript should NOT contain '$keyword'") + } + } + + @Test + fun `should load technical stop words`() { + // Arrange + val minimalStopWords = ResourceKeywords("keywords/technical-minimal.txt") + val moderateStopWords = ResourceKeywords("keywords/technical-moderate.txt") + val aggressiveStopWords = ResourceKeywords("keywords/technical-aggressive.txt") + + // Act + val minimalSet = minimalStopWords.getKeywords() + val moderateSet = moderateStopWords.getKeywords() + val aggressiveSet = aggressiveStopWords.getKeywords() + + // Assert + assertTrue(minimalSet.isNotEmpty(), "Minimal stop words should not be empty") + assertTrue(moderateSet.isNotEmpty(), "Moderate stop words should not be empty") + assertTrue(aggressiveSet.isNotEmpty(), "Aggressive stop words should not be empty") + } + + @Test + fun `should load framework keywords`() { + // Arrange & Act & Assert + assertTrue(ResourceKeywords("keywords/angular-keywords.txt").getKeywords().isNotEmpty()) + assertTrue(ResourceKeywords("keywords/react-keywords.txt").getKeywords().isNotEmpty()) + assertTrue(ResourceKeywords("keywords/aspnet-keywords.txt").getKeywords().isNotEmpty()) + assertTrue(ResourceKeywords("keywords/entityframework-keywords.txt").getKeywords().isNotEmpty()) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/SourceCodePipelineTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/SourceCodePipelineTest.kt new file mode 100644 index 0000000000..73755b474f --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/SourceCodePipelineTest.kt @@ -0,0 +1,502 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.ExtractionWeights +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.Language +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.StopWordFilter +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.keywords.ResourceKeywords +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SourceCodePipelineTest { + private val emptyFilter = StopWordFilter(emptyList(), emptySet()) + private val kotlinKeywordsFilter = StopWordFilter(listOf(ResourceKeywords("keywords/kotlin-keywords.txt")), emptySet()) + + @Test + fun `should process simple class through entire pipeline`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + class UserProfile { + val userName = "John" + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("user")) + assertTrue(result.containsKey("profile")) + assertTrue(result.containsKey("name")) + assertTrue(result["user"]!! > 0) + } + + @Test + fun `should return empty map for blank source code`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + stopWordFilter = emptyFilter + ) + val sourceCode = " " + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.isEmpty()) + } + + @Test + fun `should return empty map for empty source code`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + stopWordFilter = emptyFilter + ) + val sourceCode = "" + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.isEmpty()) + } + + @Test + fun `should generate bigrams with ngrams 2`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 2, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + class UserProfile + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("user")) + assertTrue(result.containsKey("profile")) + assertTrue(result.containsKey("user profile")) + } + + @Test + fun `should generate trigrams with ngrams 3`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 3, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + class CustomerOrderProcessor + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert - SSR removes bigrams when trigram has equal frequency + assertTrue(result.containsKey("customer")) + assertTrue(result.containsKey("order")) + assertTrue(result.containsKey("processor")) + // Bigrams removed by SSR (contained in trigram with equal frequency) + assertFalse(result.containsKey("customer order")) + assertFalse(result.containsKey("order processor")) + assertTrue(result.containsKey("customer order processor")) + } + + @Test + fun `should apply correct weights to different contexts`() { + // Arrange + val weights = + ExtractionWeights( + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1 + ) + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = weights, + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + // Process customer orders + class OrderProcessor { + fun process() { + println("process orders") + } + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("process")) + assertTrue(result.containsKey("order")) + assertTrue(result.containsKey("orders")) + + // "process" appears as: + // - identifier in OrderProcessor (weight 3) → "process" from "processor" split + // - identifier in function name (weight 3) + // - comment (weight 2) + // - string (weight 1) + // Total expected: 3 + 3 + 2 + 1 = 9 + assertTrue(result["process"]!! >= 6) // At least identifier + comment + } + + @Test + fun `should aggregate duplicate identifiers`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + class User { + val userId: String + val userName: String + val userEmail: String + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("user")) + // "user" appears 4 times (class name + 3 properties) with weight 3 each = 12 + assertEquals(12, result["user"]) + } + + @Test + fun `should filter Kotlin language keywords`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = kotlinKeywordsFilter + ) + val sourceCode = + """ + class UserProfile { + val name: String + fun process() {} + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + // Should extract domain words + assertTrue(result.containsKey("user")) + assertTrue(result.containsKey("profile")) + assertTrue(result.containsKey("name")) + assertTrue(result.containsKey("process")) + + // Should not extract Kotlin keywords (class, val, fun are filtered) + // Note: These wouldn't be extracted anyway since they're keywords, + // but this test verifies the filter integration + } + + @Test + fun `should process complex class with multiple members`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + data class Customer( + val customerId: String, + val customerName: String, + val customerEmail: String + ) { + fun validateCustomer(): Boolean { + return true + } + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("customer")) + assertTrue(result.containsKey("name")) + assertTrue(result.containsKey("email")) + assertTrue(result.containsKey("validate")) + + // "customer" appears 5 times (class + 3 properties + 1 function) with weight 3 each = 15 + assertEquals(15, result["customer"]) + } + + @Test + fun `should process enum class declaration`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + enum class OrderStatus { + PENDING, + ACTIVE, + COMPLETED + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("order")) + assertTrue(result.containsKey("status")) + } + + @Test + fun `should extract words from comments and strings with correct weights`() { + // Arrange + val weights = + ExtractionWeights( + identifierWeight = 3, + commentWeight = 2, + stringWeight = 1 + ) + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = weights, + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + // Calculate the total amount + fun calculate() { + val result = "total amount calculated" + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("calculate")) + assertTrue(result.containsKey("total")) + assertTrue(result.containsKey("amount")) + + // "calculate" appears as: + // - identifier (weight 3) + // - comment (weight 2) + // Total: 5 + assertEquals(5, result["calculate"]) + + // "total" appears as: + // - comment (weight 2) + // - string (weight 1) + // Total: 3 + assertEquals(3, result["total"]) + } + + @Test + fun `should handle interface declarations`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + interface Drawable { + fun draw() + fun render() + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("drawable")) + assertTrue(result.containsKey("draw")) + assertTrue(result.containsKey("render")) + } + + @Test + fun `should handle object declarations`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + object Singleton { + val instance = "singleton" + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("singleton")) + assertTrue(result.containsKey("instance")) + } + + @Test + fun `should process nested classes`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + class Outer { + class Inner { + val innerProperty = "test" + } + val outerProperty = "test" + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("outer")) + assertTrue(result.containsKey("inner")) + assertTrue(result.containsKey("property")) + } + + @Test + fun `should handle sealed class hierarchies`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + sealed class Result { + data class Success(val data: String) : Result() + data class Error(val message: String) : Result() + } + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("result")) + assertTrue(result.containsKey("success")) + assertTrue(result.containsKey("error")) + assertTrue(result.containsKey("data")) + assertTrue(result.containsKey("message")) + } + + @Test + fun `should generate bigrams only for identifiers not comments`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 2, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + // This is a comment with multiple words + class UserProfile + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + // Should have bigrams from identifier + assertTrue(result.containsKey("user profile")) + + // Should NOT have bigrams from comment + assertTrue(!result.containsKey("comment with") || result["comment with"] == null) + } + + @Test + fun `should process typealias declarations`() { + // Arrange + val pipeline = + SourceCodePipeline( + Language.KOTLIN, + weights = ExtractionWeights(), + ngrams = 1, + stopWordFilter = emptyFilter + ) + val sourceCode = + """ + typealias UserId = String + typealias CustomerList = List + """.trimIndent() + + // Act + val result = pipeline.process(sourceCode) + + // Assert + assertTrue(result.containsKey("user")) + assertTrue(result.containsKey("customer")) + assertTrue(result.containsKey("list")) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/ExtractStageTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/ExtractStageTest.kt new file mode 100644 index 0000000000..ac59f9ab13 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/ExtractStageTest.kt @@ -0,0 +1,395 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.ExtractionContext +import de.maibornwolff.treesitter.excavationsite.api.Language +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ExtractStageTest { + private val stage = ExtractStage(Language.KOTLIN) + + @Test + fun `should return empty list for blank source code`() { + // Arrange + val sourceCode = " " + + // Act + val result = stage.extract(sourceCode) + + // Assert + assertTrue(result.isEmpty()) + } + + @Test + fun `should return empty list for empty source code`() { + // Arrange + val sourceCode = "" + + // Act + val result = stage.extract(sourceCode) + + // Assert + assertTrue(result.isEmpty()) + } + + @Test + fun `should extract single-line comments`() { + // Arrange + val sourceCode = + """ + // This is a comment + // Another comment here + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val comments = result.filter { it.context == ExtractionContext.COMMENT } + assertEquals(2, comments.size) + assertTrue(comments.any { it.text.contains("This is a comment") }) + assertTrue(comments.any { it.text.contains("Another comment here") }) + } + + @Test + fun `should extract multi-line comments`() { + // Arrange + val sourceCode = + """ + /* + * This is a multi-line comment + * with multiple lines + */ + fun test() {} + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val comments = result.filter { it.context == ExtractionContext.COMMENT } + assertTrue(comments.isNotEmpty()) + val multiLineComment = comments.first() + assertTrue(multiLineComment.text.contains("multi-line comment")) + } + + @Test + fun `should extract double-quoted strings`() { + // Arrange + val sourceCode = + """ + val message = "Hello World" + val greeting = "Welcome to Kotlin" + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val strings = result.filter { it.context == ExtractionContext.STRING } + assertTrue(strings.any { it.text == "Hello World" }) + assertTrue(strings.any { it.text == "Welcome to Kotlin" }) + } + + @Test + fun `should extract character literals`() { + // Arrange + val sourceCode = + """ + val char = 'a' + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + // Note: Single character literals in Kotlin are char types, not strings + // The library may or may not extract these depending on the tree-sitter grammar + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + assertTrue(identifiers.any { it.text == "char" }) + } + + @Test + fun `should extract triple-quoted strings`() { + // Arrange + val sourceCode = + """ + val text = ${"\"\"\""} + This is a triple-quoted string + with multiple lines + ${"\"\"\""} + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val strings = result.filter { it.context == ExtractionContext.STRING } + assertTrue(strings.isNotEmpty()) + val tripleQuoted = strings.first() + assertTrue(tripleQuoted.text.contains("triple-quoted string")) + } + + @Test + fun `should extract all string lengths`() { + // Arrange + val sourceCode = + """ + val short = "ab" + val long = "Hello" + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + // Note: The library extracts all strings regardless of length + // Filtering by length is done in later pipeline stages if needed + val strings = result.filter { it.context == ExtractionContext.STRING } + assertTrue(strings.any { it.text == "Hello" }) + assertTrue(strings.any { it.text == "ab" }) + } + + @Test + fun `should extract identifiers from class declaration`() { + // Arrange + val sourceCode = + """ + class UserProfile { + val userName = "John" + } + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + assertTrue(identifiers.any { it.text == "UserProfile" }) + assertTrue(identifiers.any { it.text == "userName" }) + } + + @Test + fun `should extract identifiers from function declaration`() { + // Arrange + val sourceCode = + """ + fun calculateTotal(price: Double, quantity: Int): Double { + return price * quantity + } + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + assertTrue(identifiers.any { it.text == "calculateTotal" }) + assertTrue(identifiers.any { it.text == "price" }) + assertTrue(identifiers.any { it.text == "quantity" }) + } + + @Test + fun `should extract all contexts from mixed source code`() { + // Arrange + val sourceCode = + """ + // Process user orders + class OrderProcessor { + fun process(order: String) { + println("Processing order") + } + } + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + val comments = result.filter { it.context == ExtractionContext.COMMENT } + val strings = result.filter { it.context == ExtractionContext.STRING } + + assertTrue(identifiers.isNotEmpty()) + assertTrue(comments.isNotEmpty()) + assertTrue(strings.isNotEmpty()) + + assertTrue(identifiers.any { it.text == "OrderProcessor" }) + assertTrue(identifiers.any { it.text == "process" }) + assertTrue(comments.any { it.text.contains("Process user orders") }) + assertTrue(strings.any { it.text == "Processing order" }) + } + + @Test + fun `should handle escaped quotes in strings`() { + // Arrange + val sourceCode = + """ + val text = "Hello \"World\"" + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val strings = result.filter { it.context == ExtractionContext.STRING } + assertTrue(strings.isNotEmpty()) + } + + @Test + fun `should extract from data class with multiple properties`() { + // Arrange + val sourceCode = + """ + data class Customer( + val customerId: String, + val customerName: String, + val customerEmail: String + ) + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + assertTrue(identifiers.any { it.text == "Customer" }) + assertTrue(identifiers.any { it.text == "customerId" }) + assertTrue(identifiers.any { it.text == "customerName" }) + assertTrue(identifiers.any { it.text == "customerEmail" }) + } + + @Test + fun `should extract from enum class`() { + // Arrange + val sourceCode = + """ + enum class Status { + PENDING, + ACTIVE, + COMPLETED + } + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + assertTrue(identifiers.any { it.text == "Status" }) + assertTrue(identifiers.any { it.text == "PENDING" }) + assertTrue(identifiers.any { it.text == "ACTIVE" }) + assertTrue(identifiers.any { it.text == "COMPLETED" }) + } + + @Test + fun `should handle comments with special characters`() { + // Arrange + val sourceCode = + """ + // TODO: Fix this! @important #123 + fun test() {} + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val comments = result.filter { it.context == ExtractionContext.COMMENT } + assertTrue(comments.any { it.text.contains("TODO") }) + assertTrue(comments.any { it.text.contains("important") }) + } + + @Test + fun `should extract from nested classes`() { + // Arrange + val sourceCode = + """ + class Outer { + class Inner { + val innerProperty = "test" + } + } + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + assertTrue(identifiers.any { it.text == "Outer" }) + assertTrue(identifiers.any { it.text == "Inner" }) + assertTrue(identifiers.any { it.text == "innerProperty" }) + } + + @Test + fun `should extract from interface declaration`() { + // Arrange + val sourceCode = + """ + interface Drawable { + fun draw() + } + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + assertTrue(identifiers.any { it.text == "Drawable" }) + assertTrue(identifiers.any { it.text == "draw" }) + } + + @Test + fun `should extract from object declaration`() { + // Arrange + val sourceCode = + """ + object Singleton { + val instance = "singleton" + } + """.trimIndent() + + // Act + val result = stage.extract(sourceCode) + + // Assert + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + assertTrue(identifiers.any { it.text == "Singleton" }) + assertTrue(identifiers.any { it.text == "instance" }) + } + + @Test + fun `should extract identifiers comments and strings from Rust source code`() { + // Arrange + val rustStage = ExtractStage(Language.RUST) + val sourceCode = + """ + // Represents a customer order + struct OrderProcessor { + order_id: String, + } + + impl OrderProcessor { + fn process(&self) { + let message = "Processing order"; + println!("{}", message); + } + } + """.trimIndent() + + // Act + val result = rustStage.extract(sourceCode) + + // Assert + val identifiers = result.filter { it.context == ExtractionContext.IDENTIFIER } + val comments = result.filter { it.context == ExtractionContext.COMMENT } + val strings = result.filter { it.context == ExtractionContext.STRING } + assertTrue(identifiers.any { it.text == "OrderProcessor" }) + assertTrue(identifiers.any { it.text == "process" }) + assertTrue(comments.any { it.text.contains("customer order") }) + assertTrue(strings.any { it.text == "Processing order" }) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/NgramsStageTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/NgramsStageTest.kt new file mode 100644 index 0000000000..f7af681176 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/NgramsStageTest.kt @@ -0,0 +1,487 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.ExtractionContext +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.WeightedText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NgramsStageTest { + @Test + fun `should return individual words when ngrams is 1`() { + // Arrange + val stage = NgramsStage(ngrams = 1) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("user", 3, ExtractionContext.IDENTIFIER), + WeightedText("profile", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("user", "profile"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert + assertEquals(2, result.size) + assertEquals("user", result[0].text) + assertEquals("profile", result[1].text) + } + + @Test + fun `should generate bigrams from identifier with ngrams 2`() { + // Arrange + val stage = NgramsStage(ngrams = 2) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("user", 3, ExtractionContext.IDENTIFIER), + WeightedText("profile", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("user", "profile"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert + assertEquals(3, result.size) + assertEquals("user", result[0].text) + assertEquals("profile", result[1].text) + assertEquals("user profile", result[2].text) + assertEquals(3, result[2].weight) + assertEquals(ExtractionContext.IDENTIFIER, result[2].context) + } + + @Test + fun `should generate trigrams from identifier with ngrams 3`() { + // Arrange + val stage = NgramsStage(ngrams = 3) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("customer", 3, ExtractionContext.IDENTIFIER), + WeightedText("order", 3, ExtractionContext.IDENTIFIER), + WeightedText("processor", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("customer", "order", "processor"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert - SSR removes bigrams when trigram has equal frequency + assertEquals(4, result.size) + // Individual words + assertEquals("customer", result[0].text) + assertEquals("order", result[1].text) + assertEquals("processor", result[2].text) + // Trigram (bigrams removed by SSR since they have equal frequency) + assertEquals("customer order processor", result[3].text) + } + + @Test + fun `should not generate ngrams from comments`() { + // Arrange + val stage = NgramsStage(ngrams = 2) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("process", 2, ExtractionContext.COMMENT), + WeightedText("customer", 2, ExtractionContext.COMMENT), + WeightedText("orders", 2, ExtractionContext.COMMENT) + ), + sourceWords = listOf("process", "customer", "orders"), + weight = 2, + context = ExtractionContext.COMMENT + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert + assertEquals(3, result.size) + assertEquals("process", result[0].text) + assertEquals("customer", result[1].text) + assertEquals("orders", result[2].text) + } + + @Test + fun `should not generate ngrams from strings`() { + // Arrange + val stage = NgramsStage(ngrams = 2) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("hello", 1, ExtractionContext.STRING), + WeightedText("world", 1, ExtractionContext.STRING) + ), + sourceWords = listOf("hello", "world"), + weight = 1, + context = ExtractionContext.STRING + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert + assertEquals(2, result.size) + assertEquals("hello", result[0].text) + assertEquals("world", result[1].text) + } + + @Test + fun `should handle empty input`() { + // Arrange + val stage = NgramsStage(ngrams = 2) + val splitResults = emptyList() + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert + assertTrue(result.isEmpty()) + } + + @Test + fun `should handle single word identifier without generating ngrams`() { + // Arrange + val stage = NgramsStage(ngrams = 2) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("user", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("user"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert + assertEquals(1, result.size) + assertEquals("user", result[0].text) + } + + @Test + fun `should limit ngrams to available words count`() { + // Arrange + val stage = NgramsStage(ngrams = 5) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("user", 3, ExtractionContext.IDENTIFIER), + WeightedText("profile", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("user", "profile"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert + assertEquals(3, result.size) + assertEquals("user", result[0].text) + assertEquals("profile", result[1].text) + assertEquals("user profile", result[2].text) + } + + @Test + fun `should generate all ngrams up to specified size`() { + // Arrange + val stage = NgramsStage(ngrams = 4) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("user", 3, ExtractionContext.IDENTIFIER), + WeightedText("profile", 3, ExtractionContext.IDENTIFIER), + WeightedText("data", 3, ExtractionContext.IDENTIFIER), + WeightedText("manager", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("user", "profile", "data", "manager"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert - SSR removes all shorter n-grams when 4-gram has equal frequency + assertEquals(5, result.size) + // Individual words (4) - unigrams are never removed + assertEquals("user", result[0].text) + assertEquals("profile", result[1].text) + assertEquals("data", result[2].text) + assertEquals("manager", result[3].text) + // Only the longest n-gram remains (bigrams and trigrams removed by SSR) + assertEquals("user profile data manager", result[4].text) + } + + @Test + fun `should handle multiple split results with mixed contexts`() { + // Arrange + val stage = NgramsStage(ngrams = 2) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("user", 3, ExtractionContext.IDENTIFIER), + WeightedText("profile", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("user", "profile"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ), + SplitStage.SplitResult( + words = + listOf( + WeightedText("process", 2, ExtractionContext.COMMENT), + WeightedText("data", 2, ExtractionContext.COMMENT) + ), + sourceWords = listOf("process", "data"), + weight = 2, + context = ExtractionContext.COMMENT + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert + assertEquals(5, result.size) + // From identifier + assertEquals("user", result[0].text) + assertEquals("profile", result[1].text) + assertEquals("user profile", result[2].text) + // From comment (no ngrams) + assertEquals("process", result[3].text) + assertEquals("data", result[4].text) + } + + // SSR (Statistical Substring Reduction) tests + + @Test + fun `should remove bigram when trigram has equal frequency via SSR`() { + // Arrange - "customer order" and "customer order service" each appear once with weight 3 + // When frequencies are equal, keep only the longer n-gram + val stage = NgramsStage(ngrams = 3) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("customer", 3, ExtractionContext.IDENTIFIER), + WeightedText("order", 3, ExtractionContext.IDENTIFIER), + WeightedText("service", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("customer", "order", "service"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert - should keep unigrams and trigram, remove bigrams that are substrings of trigram + val texts = result.map { it.text } + assertTrue("customer" in texts, "unigram 'customer' should be kept") + assertTrue("order" in texts, "unigram 'order' should be kept") + assertTrue("service" in texts, "unigram 'service' should be kept") + assertTrue("customer order service" in texts, "trigram should be kept") + assertTrue("customer order" !in texts, "bigram 'customer order' should be removed by SSR") + assertTrue("order service" !in texts, "bigram 'order service' should be removed by SSR") + } + + @Test + fun `should keep both ngrams when shorter has higher frequency`() { + // Arrange - "customer order" appears twice (weight=6), "customer order service" appears once (weight=3) + // When shorter has higher frequency, keep both + val stage = NgramsStage(ngrams = 3) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("customer", 3, ExtractionContext.IDENTIFIER), + WeightedText("order", 3, ExtractionContext.IDENTIFIER), + WeightedText("service", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("customer", "order", "service"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ), + SplitStage.SplitResult( + words = + listOf( + WeightedText("customer", 3, ExtractionContext.IDENTIFIER), + WeightedText("order", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("customer", "order"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert - "customer order" has frequency 6 (3+3), trigram has 3, so both kept + val texts = result.map { it.text } + assertTrue("customer order" in texts, "bigram should be kept when it has higher frequency") + assertTrue("customer order service" in texts, "trigram should always be kept") + } + + @Test + fun `should never remove unigrams via SSR`() { + // Arrange - unigrams should never be removed, even if contained in n-grams with equal frequency + val stage = NgramsStage(ngrams = 2) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("user", 3, ExtractionContext.IDENTIFIER), + WeightedText("profile", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("user", "profile"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert - unigrams should be kept even though bigram has same frequency + val texts = result.map { it.text } + assertTrue("user" in texts, "unigram 'user' should never be removed") + assertTrue("profile" in texts, "unigram 'profile' should never be removed") + } + + @Test + fun `should remove all substrings of longest ngram when frequencies equal`() { + // Arrange - all ngrams appear once with same frequency + // SSR should keep only the longest one (plus unigrams) + val stage = NgramsStage(ngrams = 3) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("customer", 3, ExtractionContext.IDENTIFIER), + WeightedText("order", 3, ExtractionContext.IDENTIFIER), + WeightedText("service", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("customer", "order", "service"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert + val texts = result.map { it.text } + assertEquals(4, texts.size, "should have 3 unigrams + 1 trigram") + assertTrue("customer order" !in texts, "'customer order' should be removed") + assertTrue("order service" !in texts, "'order service' should be removed") + } + + @Test + fun `should not apply SSR when ngrams is 1`() { + // Arrange - SSR only applies when ngrams > 1 + val stage = NgramsStage(ngrams = 1) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("customer", 3, ExtractionContext.IDENTIFIER), + WeightedText("order", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("customer", "order"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert - just unigrams, no SSR needed + assertEquals(2, result.size) + assertEquals("customer", result[0].text) + assertEquals("order", result[1].text) + } + + @Test + fun `should not apply SSR when enableSsr is false`() { + // Arrange - SSR can be disabled with enableSsr=false + val stage = NgramsStage(ngrams = 3, enableSsr = false) + val splitResults = + listOf( + SplitStage.SplitResult( + words = + listOf( + WeightedText("customer", 3, ExtractionContext.IDENTIFIER), + WeightedText("order", 3, ExtractionContext.IDENTIFIER), + WeightedText("service", 3, ExtractionContext.IDENTIFIER) + ), + sourceWords = listOf("customer", "order", "service"), + weight = 3, + context = ExtractionContext.IDENTIFIER + ) + ) + + // Act + val result = stage.generateNgrams(splitResults) + + // Assert - with SSR disabled, all n-grams are kept + val texts = result.map { it.text } + assertEquals(6, texts.size) + assertTrue("customer" in texts) + assertTrue("order" in texts) + assertTrue("service" in texts) + assertTrue("customer order" in texts) + assertTrue("order service" in texts) + assertTrue("customer order service" in texts) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/SplitStageTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/SplitStageTest.kt new file mode 100644 index 0000000000..44246a2c39 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/pipeline/stages/SplitStageTest.kt @@ -0,0 +1,284 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.stages + +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.ExtractionContext +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.pipeline.WeightedText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SplitStageTest { + @Test + fun `should split PascalCase words in comments`() { + // Arrange + val stage = SplitStage() + val input = + listOf( + WeightedText("IllegalArgumentException", 2, ExtractionContext.COMMENT) + ) + + // Act + val result = stage.split(input) + + // Assert + assertEquals(1, result.size) + val words = result.first().words.map { it.text } + assertEquals(listOf("illegal", "argument", "exception"), words) + } + + @Test + fun `should split camelCase words in comments`() { + // Arrange + val stage = SplitStage() + val input = + listOf( + WeightedText("getUserName", 2, ExtractionContext.COMMENT) + ) + + // Act + val result = stage.split(input) + + // Assert + assertEquals(1, result.size) + val words = result.first().words.map { it.text } + assertEquals(listOf("get", "user", "name"), words) + } + + @Test + fun `should split PascalCase words in strings`() { + // Arrange + val stage = SplitStage() + val input = + listOf( + WeightedText("NullPointerException", 1, ExtractionContext.STRING) + ) + + // Act + val result = stage.split(input) + + // Assert + assertEquals(1, result.size) + val words = result.first().words.map { it.text } + assertEquals(listOf("null", "pointer", "exception"), words) + } + + @Test + fun `should split identifiers correctly`() { + // Arrange + val stage = SplitStage() + val input = + listOf( + WeightedText("getUserProfile", 3, ExtractionContext.IDENTIFIER) + ) + + // Act + val result = stage.split(input) + + // Assert + assertEquals(1, result.size) + val words = result.first().words.map { it.text } + assertEquals(listOf("get", "user", "profile"), words) + } + + @Test + fun `should handle mixed content in comments`() { + // Arrange + val stage = SplitStage() + val input = + listOf( + WeightedText("Throws IllegalArgumentException when invalid", 2, ExtractionContext.COMMENT) + ) + + // Act + val result = stage.split(input) + + // Assert + assertEquals(1, result.size) + val words = + result + .first() + .words + .map { it.text } + .toSet() + assertEquals(setOf("throws", "illegal", "argument", "exception", "when", "invalid"), words) + } + + @Test + fun `should preserve weight and context after splitting`() { + // Arrange + val stage = SplitStage() + val input = + listOf( + WeightedText("IllegalArgumentException", 2, ExtractionContext.COMMENT) + ) + + // Act + val result = stage.split(input) + + // Assert + assertEquals(1, result.size) + val splitResult = result.first() + assertEquals(2, splitResult.weight) + assertEquals(ExtractionContext.COMMENT, splitResult.context) + splitResult.words.forEach { word -> + assertEquals(2, word.weight) + assertEquals(ExtractionContext.COMMENT, word.context) + } + } + + @Test + fun `should filter words shorter than minimum length`() { + // Arrange + val stage = SplitStage() + val input = + listOf( + WeightedText("a to be or not", 2, ExtractionContext.COMMENT) + ) + + // Act + val result = stage.split(input) + + // Assert + assertEquals(1, result.size) + val words = result.first().words.map { it.text } + assertEquals(listOf("not"), words) + } + + @Test + fun `should handle empty input`() { + // Arrange + val stage = SplitStage() + val input = emptyList() + + // Act + val result = stage.split(input) + + // Assert + assertTrue(result.isEmpty()) + } + + @Test + fun `should split customerOrder identifier`() { + // Arrange + val stage = SplitStage() + val input = listOf(WeightedText("customerOrder", 3, ExtractionContext.IDENTIFIER)) + + // Act + val result = stage.split(input) + + // Assert + val words = result.first().words.map { it.text } + assertEquals(listOf("customer", "order"), words) + } + + @Test + fun `should split XMLParser identifier with acronym`() { + // Arrange + val stage = SplitStage() + val input = listOf(WeightedText("XMLParser", 3, ExtractionContext.IDENTIFIER)) + + // Act + val result = stage.split(input) + + // Assert + val words = result.first().words.map { it.text } + assertEquals(listOf("xml", "parser"), words) + } + + @Test + fun `should split getHTTPResponse identifier with acronym`() { + // Arrange + val stage = SplitStage() + val input = listOf(WeightedText("getHTTPResponse", 3, ExtractionContext.IDENTIFIER)) + + // Act + val result = stage.split(input) + + // Assert + val words = result.first().words.map { it.text } + assertEquals(listOf("get", "http", "response"), words) + } + + @Test + fun `should split HTTPServer identifier with acronym`() { + // Arrange + val stage = SplitStage() + val input = listOf(WeightedText("HTTPServer", 3, ExtractionContext.IDENTIFIER)) + + // Act + val result = stage.split(input) + + // Assert + val words = result.first().words.map { it.text } + assertEquals(listOf("http", "server"), words) + } + + @Test + fun `should split snake_case identifier`() { + // Arrange + val stage = SplitStage() + val input = listOf(WeightedText("user_profile_id", 3, ExtractionContext.IDENTIFIER)) + + // Act + val result = stage.split(input) + + // Assert + val words = result.first().words.map { it.text } + assertEquals(listOf("user", "profile", "id"), words) + } + + @Test + fun `should split SCREAMING_SNAKE_CASE identifier`() { + // Arrange + val stage = SplitStage() + val input = listOf(WeightedText("CONSTANT_VALUE", 3, ExtractionContext.IDENTIFIER)) + + // Act + val result = stage.split(input) + + // Assert + val words = result.first().words.map { it.text } + assertEquals(listOf("constant", "value"), words) + } + + @Test + fun `should split XMLExtendedParser identifier with acronym`() { + // Arrange + val stage = SplitStage() + val input = listOf(WeightedText("XMLExtendedParser", 3, ExtractionContext.IDENTIFIER)) + + // Act + val result = stage.split(input) + + // Assert + val words = result.first().words.map { it.text } + assertEquals(listOf("xml", "extended", "parser"), words) + } + + @Test + fun `should preserve alphanumeric terms like 3D in identifiers`() { + // Arrange + val stage = SplitStage() + val input = listOf(WeightedText("Export3DMapButtonComponent", 3, ExtractionContext.IDENTIFIER)) + + // Act + val result = stage.split(input) + + // Assert + val words = result.first().words.map { it.text } + assertEquals(listOf("export", "3d", "map", "button", "component"), words) + } + + @Test + fun `should strip special characters from identifiers`() { + // Arrange + val stage = SplitStage() + val input = listOf(WeightedText("maxTreeMapFiles\$", 3, ExtractionContext.IDENTIFIER)) + + // Act + val result = stage.split(input) + + // Assert + val words = result.first().words.map { it.text } + assertEquals(listOf("max", "tree", "map", "files"), words) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/stopwords/DlcIgnoreParserTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/stopwords/DlcIgnoreParserTest.kt new file mode 100644 index 0000000000..59ebdd9bff --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/processing/stopwords/DlcIgnoreParserTest.kt @@ -0,0 +1,268 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.processing.stopwords + +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DlcIgnoreParserTest { + private val parser = DlcIgnoreParser() + + @Test + fun `should return empty set when dlcignore file does not exist`( + @TempDir tempDir: Path + ) { + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertTrue(stopWords.isEmpty()) + } + + @Test + fun `should parse single stop word from dlcignore file`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText("custom") + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertEquals(setOf("custom"), stopWords) + } + + @Test + fun `should parse multiple stop words from dlcignore file`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText( + """ + domain + custom + project + """.trimIndent() + ) + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertEquals(setOf("domain", "custom", "project"), stopWords) + } + + @Test + fun `should ignore empty lines in dlcignore file`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText( + """ + word1 + + word2 + + + word3 + """.trimIndent() + ) + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertEquals(setOf("word1", "word2", "word3"), stopWords) + } + + @Test + fun `should ignore comment lines starting with hash in dlcignore file`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText( + """ + # This is a comment + word1 + # Another comment + word2 + #comment without space + word3 + """.trimIndent() + ) + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertEquals(setOf("word1", "word2", "word3"), stopWords) + } + + @Test + fun `should trim whitespace from stop words in dlcignore file`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText( + """ + word1 + word2 + word3 + """.trimIndent() + ) + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertEquals(setOf("word1", "word2", "word3"), stopWords) + } + + @Test + fun `should convert stop words to lowercase in dlcignore file`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText( + """ + UPPERCASE + MixedCase + lowercase + """.trimIndent() + ) + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertEquals(setOf("uppercase", "mixedcase", "lowercase"), stopWords) + } + + @Test + fun `should handle dlcignore file with comments empty lines and mixed case`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText( + """ + # Project-specific stop words + CustomerID + OrderID + + # Domain terms to exclude + ProductSKU + InvoiceNumber + + # Common abbreviations + acct + qty + """.trimIndent() + ) + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertEquals( + setOf("customerid", "orderid", "productsku", "invoicenumber", "acct", "qty"), + stopWords + ) + } + + @Test + fun `should return empty set when dlcignore is a directory not a file`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreDir = tempDir.resolve(".dlcignore").toFile() + dlcignoreDir.mkdir() + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertTrue(stopWords.isEmpty()) + } + + @Test + fun `should handle empty dlcignore file`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText("") + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertTrue(stopWords.isEmpty()) + } + + @Test + fun `should handle dlcignore file with only comments`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText( + """ + # Comment 1 + # Comment 2 + # Comment 3 + """.trimIndent() + ) + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertTrue(stopWords.isEmpty()) + } + + @Test + fun `should handle dlcignore file with only empty lines`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText("\n\n\n\n") + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertTrue(stopWords.isEmpty()) + } + + @Test + fun `should deduplicate stop words in dlcignore file`( + @TempDir tempDir: Path + ) { + // Arrange + val dlcignoreFile = tempDir.resolve(".dlcignore").toFile() + dlcignoreFile.writeText( + """ + word + word + WORD + Word + """.trimIndent() + ) + + // Act + val stopWords = parser.loadCustomStopWords(tempDir) + + // Assert + assertEquals(setOf("word"), stopWords) + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporterFactoryTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporterFactoryTest.kt new file mode 100644 index 0000000000..86fe8934f4 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/ProgressReporterFactoryTest.kt @@ -0,0 +1,25 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress + +import kotlin.test.Test +import kotlin.test.assertIs + +class ProgressReporterFactoryTest { + @Test + fun `should return SilentProgressReporter when quiet is true`() { + // Arrange & Act + val reporter = ProgressReporterFactory.create(quiet = true) + + // Assert + assertIs(reporter) + } + + @Test + fun `should return non-silent reporter when quiet is false`() { + // Arrange & Act + val reporter = ProgressReporterFactory.create(quiet = false) + + // Assert - a non-silent, ProgressTracker-backed reporter + assertIs(reporter) + reporter.close() + } +} diff --git a/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/SilentProgressReporterTest.kt b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/SilentProgressReporterTest.kt new file mode 100644 index 0000000000..da3a9cc662 --- /dev/null +++ b/analysis/analysers/parsers/DomainLanguageParser/src/test/kotlin/de/maibornwolff/codecharta/analysers/parsers/domainlanguage/progress/SilentProgressReporterTest.kt @@ -0,0 +1,34 @@ +package de.maibornwolff.codecharta.analysers.parsers.domainlanguage.progress + +import kotlin.test.Test + +class SilentProgressReporterTest { + @Test + fun `should not throw on any operation`() { + // Arrange & Act & Assert - no exceptions + SilentProgressReporter.startPhase("Test", 100) + SilentProgressReporter.advance(50) + SilentProgressReporter.completePhase() + SilentProgressReporter.close() + } + + @Test + fun `should handle indeterminate phase`() { + // Arrange & Act & Assert - no exceptions + SilentProgressReporter.startPhase("Scanning", null) + SilentProgressReporter.advance() + SilentProgressReporter.completePhase() + } + + @Test + fun `should be safe for multiple phases`() { + // Arrange & Act & Assert - no exceptions + SilentProgressReporter.startPhase("Phase 1", 100) + SilentProgressReporter.advance(100) + SilentProgressReporter.completePhase() + + SilentProgressReporter.startPhase("Phase 2", 50) + SilentProgressReporter.advance(50) + SilentProgressReporter.completePhase() + } +} diff --git a/analysis/ccsh/build.gradle.kts b/analysis/ccsh/build.gradle.kts index 214a5a1035..731256c990 100644 --- a/analysis/ccsh/build.gradle.kts +++ b/analysis/ccsh/build.gradle.kts @@ -31,7 +31,8 @@ dependencies { ":analysers:parsers:UnifiedParser", ":dialogProvider", ":analysers:importers:SourceMonitorImporter", - ":analysers:importers:DependaChartaImporter" + ":analysers:importers:DependaChartaImporter", + ":analysers:parsers:DomainLanguageParser" ) projects.forEach { diff --git a/analysis/ccsh/src/main/kotlin/de/maibornwolff/codecharta/ccsh/Ccsh.kt b/analysis/ccsh/src/main/kotlin/de/maibornwolff/codecharta/ccsh/Ccsh.kt index 178125c455..7d2bcedd9b 100644 --- a/analysis/ccsh/src/main/kotlin/de/maibornwolff/codecharta/ccsh/Ccsh.kt +++ b/analysis/ccsh/src/main/kotlin/de/maibornwolff/codecharta/ccsh/Ccsh.kt @@ -12,6 +12,7 @@ import de.maibornwolff.codecharta.analysers.importers.dependacharta.DependaChart import de.maibornwolff.codecharta.analysers.importers.sonar.SonarImporter import de.maibornwolff.codecharta.analysers.importers.sourcemonitor.SourceMonitorImporter import de.maibornwolff.codecharta.analysers.importers.tokei.TokeiImporter +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.DomainLanguageParser import de.maibornwolff.codecharta.analysers.parsers.gitlog.GitLogParser import de.maibornwolff.codecharta.analysers.parsers.rawtext.RawTextParser import de.maibornwolff.codecharta.analysers.parsers.svnlog.SVNLogParser @@ -57,7 +58,8 @@ import kotlin.system.exitProcess TokeiImporter::class, DependaChartaImporter::class, RawTextParser::class, - UnifiedParser::class + UnifiedParser::class, + DomainLanguageParser::class ], versionProvider = Ccsh.ManifestVersionProvider::class, footer = [CodeChartaConstants.GENERIC_FOOTER] diff --git a/analysis/ccsh/src/test/kotlin/de/maibornwolff/codecharta/ccsh/analyser/AnalyserServiceTest.kt b/analysis/ccsh/src/test/kotlin/de/maibornwolff/codecharta/ccsh/analyser/AnalyserServiceTest.kt index 732da8ce76..5d77ac704a 100644 --- a/analysis/ccsh/src/test/kotlin/de/maibornwolff/codecharta/ccsh/analyser/AnalyserServiceTest.kt +++ b/analysis/ccsh/src/test/kotlin/de/maibornwolff/codecharta/ccsh/analyser/AnalyserServiceTest.kt @@ -64,6 +64,7 @@ class AnalyserServiceTest { Arguments.of("tokeiimporter"), Arguments.of("dependachartaimport"), Arguments.of("rawtextparser"), + Arguments.of("domainlanguageparser"), Arguments.of("check"), Arguments.of("inspect") ) @@ -148,6 +149,7 @@ class AnalyserServiceTest { "merge", "gitlogparser", "rawtextparser", + "domainlanguageparser", "sourcemonitorimport", "tokeiimporter", "dependachartaimport", diff --git a/analysis/ccsh/src/test/kotlin/de/maibornwolff/codecharta/ccsh/analyser/repository/PicocliAnalyserRepositoryTest.kt b/analysis/ccsh/src/test/kotlin/de/maibornwolff/codecharta/ccsh/analyser/repository/PicocliAnalyserRepositoryTest.kt index e5ae10db94..3d98b485f4 100644 --- a/analysis/ccsh/src/test/kotlin/de/maibornwolff/codecharta/ccsh/analyser/repository/PicocliAnalyserRepositoryTest.kt +++ b/analysis/ccsh/src/test/kotlin/de/maibornwolff/codecharta/ccsh/analyser/repository/PicocliAnalyserRepositoryTest.kt @@ -12,6 +12,7 @@ import de.maibornwolff.codecharta.analysers.importers.dependacharta.DependaChart import de.maibornwolff.codecharta.analysers.importers.sonar.SonarImporter import de.maibornwolff.codecharta.analysers.importers.sourcemonitor.SourceMonitorImporter import de.maibornwolff.codecharta.analysers.importers.tokei.TokeiImporter +import de.maibornwolff.codecharta.analysers.parsers.domainlanguage.DomainLanguageParser import de.maibornwolff.codecharta.analysers.parsers.gitlog.GitLogParser import de.maibornwolff.codecharta.analysers.parsers.rawtext.RawTextParser import de.maibornwolff.codecharta.analysers.parsers.svnlog.SVNLogParser @@ -67,7 +68,7 @@ class PicocliAnalyserRepositoryTest { SVNLogParser(), GitLogParser(), CodeMaatImporter(), TokeiImporter(), DependaChartaImporter(), - RawTextParser(), + RawTextParser(), DomainLanguageParser(), ValidationTool(), InspectionTool() ) @@ -97,6 +98,7 @@ class PicocliAnalyserRepositoryTest { listOf(TokeiImporter.NAME, " - " + TokeiImporter.DESCRIPTION), listOf(DependaChartaImporter.NAME, " - " + DependaChartaImporter.DESCRIPTION), listOf(RawTextParser.NAME, " - " + RawTextParser.DESCRIPTION), + listOf(DomainLanguageParser.NAME, " - " + DomainLanguageParser.DESCRIPTION), listOf(ValidationTool.NAME, " - " + ValidationTool.DESCRIPTION), listOf(InspectionTool.NAME, " - " + InspectionTool.DESCRIPTION) ) diff --git a/analysis/settings.gradle.kts b/analysis/settings.gradle.kts index 9cd4f84b68..2e8eadb862 100644 --- a/analysis/settings.gradle.kts +++ b/analysis/settings.gradle.kts @@ -20,7 +20,8 @@ include( "analysers:parsers:RawTextParser", "analysers:parsers:GitLogParser", "analysers:parsers:SVNLogParser", - "analysers:parsers:UnifiedParser" + "analysers:parsers:UnifiedParser", + "analysers:parsers:DomainLanguageParser" ) include("analysers:exporters:CSVExporter") include( diff --git a/gh-pages/astro.config.mjs b/gh-pages/astro.config.mjs index 0be2cfacc2..e1e739d497 100644 --- a/gh-pages/astro.config.mjs +++ b/gh-pages/astro.config.mjs @@ -102,6 +102,7 @@ export default defineConfig({ items: [ { label: 'Unified', slug: 'docs/parser/unified' }, { label: 'Raw Text', slug: 'docs/parser/raw-text' }, + { label: 'Domain Language', slug: 'docs/parser/domain-language' }, { label: 'Git Log', slug: 'docs/parser/git-log' }, { label: 'SVN Log', slug: 'docs/parser/svn-log' }, ], diff --git a/gh-pages/src/content/docs/docs/parser/domain-language.md b/gh-pages/src/content/docs/docs/parser/domain-language.md new file mode 100644 index 0000000000..aa50dadb88 --- /dev/null +++ b/gh-pages/src/content/docs/docs/parser/domain-language.md @@ -0,0 +1,62 @@ +--- +title: "Domain Language Parser" +--- + +**Category**: Parser (takes in source code and outputs cc.json) + +This parser extracts the *domain vocabulary* of a codebase. It tokenizes identifiers, comments and +string literals across the supported languages, filters out programming-language keywords and technical +stop words, and counts how often each (optionally n-gram) word occurs. The word-frequency data is +written into the reserved cc.json 2.0 **`domain` lens**, keyed by node id, for every file and +aggregated for every folder (and the project root). + +The `domain` lens payload is a bare map `{ "": [{ "text", "frequency", "tfidf"? }, ...] }`. +`tfidf` is only present when TF-IDF scoring is enabled and defined. + +### Supported Languages + +Kotlin, Java, TypeScript, JavaScript, Python, C#, Go, C, C++, PHP, Ruby, Swift, Objective-C and shell. + +### Usage and Parameters + +| Parameter | Description | +|-------------------------------------------|-------------------------------------------------------------------------------------------------| +| `FILE or FOLDER` | file/project to parse | +| `-o, --output-file=` | output file (or empty for stdout) | +| `-nc, --not-compressed` | save uncompressed output file | +| `--bypass-gitignore` | disable automatic .gitignore-based file exclusion | +| `--verbose` | verbose mode (also shows an analysis progress bar) | +| `--exclude-tests` | exclude test files from the analysis (test files are included by default) | +| `--ngrams=` | generate n-grams up to size N (1=words, 2=bigrams, 3=trigrams; default 1) | +| `--no-ssr` | disable Statistical Substring Reduction for n-grams (enabled by default when `--ngrams` > 1) | +| `--limit=` | limit each node to its top X words (all words if not set) | +| `--sort-by=` | sort words by frequency (default) or TF-IDF score | +| `--stop-word-level=` | technical stop word filtering level (default MODERATE) | +| `--exclude-technical-stopwords` | disable filtering of common technical words (e.g. `test`, `util`, `handler`) | +| `--identifier-weight=` | weight for identifier words (class/function/variable names; default 3) | +| `--comment-weight=` | weight for words in comments (default 2) | +| `--string-weight=` | weight for words in string literals (default 1) | +| `--no-tfidf` | disable TF-IDF scoring (enabled by default) | +| `-h, --help` | displays this help and exits | + +### Examples + +Analyze a project folder and write a compressed cc.json: + +``` +ccsh domainlanguageparser foo/bar/project -o out.cc.json +``` + +Keep only the top 25 words per node, ranked by TF-IDF: + +``` +ccsh domainlanguageparser foo/bar/project --limit=25 --sort-by=TFIDF -o out.cc.json +``` + +Include bigrams and trigrams and exclude test files: + +``` +ccsh domainlanguageparser foo/bar/project --ngrams=3 --exclude-tests -o out.cc.json +``` + +If a project is piped into the DomainLanguageParser, the results and the piped project are merged. diff --git a/plans/2026-07-14-domainlanguage-parser.md b/plans/2026-07-14-domainlanguage-parser.md new file mode 100644 index 0000000000..dff4fc58b6 --- /dev/null +++ b/plans/2026-07-14-domainlanguage-parser.md @@ -0,0 +1,104 @@ +--- +name: DomainLanguageParser — port DLC into ccsh as a cc.json 2.0 parser +issue: <#issueid> +state: complete +version: TBD +--- + +## Goal + +Move `DomainLanguageCharta/analysis` into `codecharta/analysis` as a first-class analyser +(`ccsh domainlanguageparser`) that emits **cc.json 2.0**, placing its word-frequency data into the +reserved **`domain` lens** (keyed by node id) over the standard `files` tree. The visualization is +out of scope; this covers the analysis/output side only. + +## Locked decisions (from clarification) + +- **Scope:** full ccsh parser (module move + `:model` + output swap + `AnalyserInterface` + `Dialog` + registration). +- **Dependencies:** lift-and-shift first (keep jgit/mordant/kasechange/kotlinx-* to get it green), dedupe jgit + mordant in a follow-up phase. +- **Domain payload:** key by cc.json 2.0 **node id** (`NodeId.fromSegments`) now, not by raw path. +- **Package:** rename to `de.maibornwolff.codecharta.analysers.parsers.domainlanguage` as part of the move. +- **Not a 1:1 port:** DLC's CLI/tool surface is not preserved. The parser adopts codecharta analyser + conventions (`CommonAnalyserParameters`, `--verbose`, piped input) and keeps only the + domain-analysis-specific flags. DLC's own `{tree, words}` JSON format and standalone entrypoint are retired. +- **Lens payload = words only, no envelope:** the `domain` lens is a bare map + `{ "": [{text, frequency, tfidf?}, ...] }` — no config/version header. Analysis parameters + (ngrams, weights, stopword level, …) live only in the CLI invocation; the front end just needs the words. + +## Tasks + +### 1. Move the module into the codecharta Gradle build (lift-and-shift) +- Create `analysers/parsers/DomainLanguageParser/` and copy DLC's `src/main`, `src/test`, and `src/main/resources` (keywords/stopwords) into it. +- Rename the Kotlin package `de.maibornwolff.domainlanguagecharta` → `de.maibornwolff.codecharta.analysers.parsers.domainlanguage` across all sources, tests, and imports; move files to the matching directory layout. +- Author a slim module `build.gradle.kts` (template: `UnifiedParser` — it also uses TreeSitter/jitpack): + - `implementation(project(":model"))`, `":analysers:AnalyserInterface"`, `":dialogProvider"`. + - Keep DLC's own deps for now: `TreeSitterExcavationSite`, jgit, mordant, kasechange, kotlinx-serialization-json (+ kotlinx-cli temporarily until Phase 4). + - **Apply `kotlin("plugin.serialization")`** in the module (needed for `@Serializable`; root does not apply it). + - Drop everything root already provides: ktlint/jacoco/sonar plugins, `jvmToolchain`, kotlin-logging, coroutines, junit/assertj/mockk. Toolchain becomes **JVM 17** (root), down from 23. +- Register in `settings.gradle.kts` (`include("analysers:parsers:DomainLanguageParser")`). +- Get it compiling: `./gradlew :analysers:parsers:DomainLanguageParser:build`. + +### 2. Swap the output layer to cc.json 2.0 with a node-id-keyed `domain` lens +- Reuse the existing analysis up to `perFileWordCounts` + `DirectoryWordAggregator` (per-file **and** per-directory word lists). Only the emit step changes. +- New writer builds a `Project` (files tree) and serializes it — template: `RawTextParser/ProjectGenerator.kt`: + - `ProjectBuilder()` + `insertByPath(Path(parentSegments), MutableNode(name, NodeType.File))` per file (folders created implicitly); set `projectName` from the input dir. + - Build the domain payload as a Gson `JsonObject`: for every path (file and aggregated directory, incl. root), key = `NodeId.fromSegments(segments, type)` with the **matching `NodeType`** (File for leaves, Folder for directories, `emptyList()` for root); value = `JsonArray` of `{text, frequency, tfidf?}`. That map IS the whole lens — no wrapper object. + - Deterministic output (the serializer checksums the file and does not canonicalize opaque lenses): emit node keys in canonical files-tree order, sort each word array by the `--sort-by` criterion desc with `text` asc as tiebreak, then apply `--limit` per node. + - `.withOpaqueLenses(mapOf(LensSet.DOMAIN_KEY to domainJson)).build()`. + - `ProjectSerializer.serializeToFileOrStream(project, outputFile, System.out, compress)` (gives meta, `apiVersion 2.0`, checksum, canonical ordering for free). +- Retire `HierarchicalOutput`, `TreeNode`, `OutputWriter` (kotlinx JSON output path). DLC's `tree` + section is fully redundant with the cc.json `files` tree — it has no successor. Preserve the + omit-`tfidf`-when-null behavior in the Gson payload (`WordFrequency.tfidf` is `@EncodeDefault(NEVER)` today). +- **Correctness check:** the domain keys must resolve against the ids the mapper emits in `files` — verify a sample id matches (same idiom the metrics lens uses). + +### 3. Make it a ccsh subcommand (AnalyserInterface + Dialog + registration) +- Create picocli `@CommandLine.Command DomainLanguageParser : CommonAnalyserParameters(), AnalyserInterface` — `name = "domainlanguageparser"`, description, `call()`, `isApplicable()`, `getDialog()`. +- **Inherit** the infrastructure options from `CommonAnalyserParameters` (positional `inputFiles`, + `-o/--output-file`, `-nc/--not-compressed`, `--bypass-gitignore`, `-e/--exclude`, `--file-extensions`, + `--verbose`). Do NOT redeclare DLC's `--directory`, `--output`, `--bypass-gitignore`, `--quiet` + (redeclaring an inherited option is a picocli startup error; `--verbose` replaces `--quiet`). +- Declare only the domain-specific `@CommandLine.Option`s: `--limit`, `--ngrams`, `--stop-word-level`, + `--identifier-weight`/`--comment-weight`/`--string-weight`, `--exclude-tests`, + `--exclude-technical-stopwords`, `--no-tfidf`, `--sort-by`, `--no-ssr` → `AnalysisConfiguration`, + then invoke `SourceAnalyzerFactory`/`SourceAnalyzer`. +- Add `Dialog : AnalyserDialogInterface` using `dialogProvider` prompts (template: `RawTextParser/Dialog.kt`). +- Retire `Main.kt` + `CliArgumentParser` (kotlinx-cli); ccsh is the sole entrypoint. +- Register (full checklist, per the last analyser added): + - `ccsh/build.gradle.kts` deps list + `Ccsh.kt` import + `subcommands` array. Interactive mode + discovers it automatically via `PicocliAnalyserRepository` — no extra registry entry. + - Update the hardcoded analyser lists in `ccsh` tests: `AnalyserServiceTest.kt` and + `PicocliAnalyserRepositoryTest.kt` (name/description entries; one test asserts exactly one + applicable analyser per fixture — write `isApplicable()` so it doesn't claim other parsers' fixtures). + - Docs: parser table in `analysis/README.md`, module `README.md`, `CHANGELOG.md`, + `gh-pages/_docs/03-parser/` page + `gh-pages/_data/navigation.yml` entry. + - Skip `AttributeGeneratorRegistry` — the output is an opaque lens, not numeric metrics (consistent + with SVNLogParser/UnifiedParser, which also aren't registered). + +### 4. Dependency dedupe (follow-up) +- Replace jgit `GitignoreParser`/`GitignoreResolver` with `AnalyserInterface`'s `GitignoreHandler`; drop **jgit**. +- Replace `MordantProgressReporter` with model `ProgressTracker` behind the existing `ProgressReporter` interface; drop **mordant**. +- Drop now-dead **kotlinx-cli** (post-picocli) and **kotlinx-serialization-json** (post-output-swap) if unused elsewhere — and with the latter, the `kotlin("plugin.serialization")` plugin added in Task 1. Also drop DLC's **kotlinx-coroutines-core** and runtime **slf4j-simple** (root/ccsh provide equivalents). **Keep kasechange** (no codecharta equivalent for identifier splitting). + +### 5. Verify end-to-end +- `./gradlew :analysers:parsers:DomainLanguageParser:test` + `ktlintFormat`/`ktlintCheck`. +- Build ccsh, run `ccsh domainlanguageparser -o out.cc.json`, then `ccsh validate out.cc.json` and `ccsh inspect out.cc.json`. +- Confirm the `domain` lens survives a `ccsh mergefilter` round-trip (opaque lenses are merge-aware). + +## Steps + +- [x] Complete Task 1: Move module into codecharta build (compiles on JVM 17) +- [x] Complete Task 2: cc.json 2.0 output with node-id-keyed `domain` lens +- [x] Complete Task 3: ccsh subcommand + Dialog + registration +- [x] Complete Task 4: Dependency dedupe (dropped jgit, mordant, kotlinx-cli, own slf4j-simple; kept kasechange + kotlinx-serialization-json for FrameworkDetector; serialization plugin removed) +- [x] Complete Task 5: End-to-end verification (parser→validate→inspect→merge round-trip all pass; full `./gradlew build -x integrationTest` SUCCESSFUL) + +## Notes + +- **Reserved slot:** `LensSet.DOMAIN_KEY = "domain"`; the payload is an opaque `JsonElement` round-tripped verbatim (`CcJsonV2SerializationTest` "reserved domain lens"). Shape is ours to define since viz is deferred. +- **TreeSitter version clash risk:** DLC pins `TreeSitterExcavationSite:v0.10.0`, UnifiedParser pins `v0.12.0`; both land on the ccsh runtime classpath. Align DLC to `v0.12.0` (or verify compatibility) to avoid a duplicate-class conflict in the fat jar. +- **JDK/Kotlin downshift:** DLC (JVM 23 / Kotlin 2.2.20) → codecharta (JVM 17 / Kotlin 2.3.10). Watch for JDK 18+ APIs; run ktlint format for the stricter house config. +- **Node-id keying:** `NodeId.fromSegments` strips `.`/`""` segments and NFC-normalizes, so DLC's `.`-rooted relative paths canonicalize cleanly; the only thing to get right is File-vs-Folder type per node. +- **Resource loading is rename-safe:** `ResourceKeywordLoader` uses resource-root-relative classpath paths (`keywords/…`, `stopwords/…`) that don't contain the Kotlin package — the rename can't break them; just keep the `resources/` layout. +- **Viz follow-up (separate plan):** the visualization currently *accepts* a `domain` lens (reserved slot in `ccJson2Schema.json`) but silently ignores it — `ccJson2ToCCFile.ts` reads only `metrics`/`dependency`, `ccjson2.model.ts` has no `domain` field, and there is no lens-switcher UI. Rendering the domain lens needs its own plan (model field + `lenses/domain/` facade + UI entry point). +- **DLC repo fate:** `DomainLanguageCharta/visualization` (standalone Angular app) consumes the old `{tree, words}` JSON and becomes orphaned by the output swap — retire it in favor of the future codecharta domain lens; the DLC repo can then be archived. +- Follow codecharta's TDD + "structural changes before behavioral" (the rename/move is structural — commit it separately from the output-behavior change). From b59a17108fc03fb05de18370fc305faa49353933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20H=C3=BChn?= Date: Mon, 20 Jul 2026 11:53:22 +0200 Subject: [PATCH 02/52] fix(visualization): stop the loading spinner from hanging on the domain view The non-map race branch skipped the very file-state emission that raised the indicator, then waited for a subsequent one. A file-panel change is usually the last emission there is, so on /domain the spinner hung until the 60s deadline. Distinguish the two raisers: a file-panel change has already landed and settles on the current file set, while a load raises the indicator before the fetch and must wait for filesLoaded first. isPendingHeavyDispatch$ had the same shape of defect: only renderCodeMap$ cleared it, and the blacklist actions that set it are not in actionsRequiringRerender, so a dispatch that changed nothing latched it true for the rest of the session. Add a backstop deadline that cannot outlive its own dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-20-per-view-loading-indicator.md | 96 +++++++++++++++++++ .../setLoadingIndicator.effect.spec.ts | 74 +++++++++++++- .../setLoadingIndicator.effect.ts | 67 ++++++++++++- .../util/dispatchAfterPaint.spec.ts | 77 +++++++++++++++ .../app/codeCharta/util/dispatchAfterPaint.ts | 17 ++++ 5 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 plans/2026-07-20-per-view-loading-indicator.md create mode 100644 visualization/app/codeCharta/util/dispatchAfterPaint.spec.ts diff --git a/plans/2026-07-20-per-view-loading-indicator.md b/plans/2026-07-20-per-view-loading-indicator.md new file mode 100644 index 0000000000..77a6b77d3a --- /dev/null +++ b/plans/2026-07-20-per-view-loading-indicator.md @@ -0,0 +1,96 @@ +--- +name: Per-view loading indicator +issue: <#issueid> +state: todo +version: 1 +--- + +## Goal + +Replace the single global loading spinner with a per-view readiness model: each routed view (Metrics, +Domain) shows its own spinner for its own work, and the 3D map render is deferred until the Metrics +view is actually on screen. Land a minimal fix for the two current hang bugs first, so the branch is +shippable at every commit. + +## Tasks + +### 1. Stop the current spinner from hanging (behavioral fix, own commit) + +Two independent defects make the global spinner stick: + +- `setLoadingIndicator.effect.ts:59-64` — the non-map race branch subscribes to + `visibleFileStatesSelector` *inside* the `switchMap` and applies `skip(1)`. `store.select` replays + synchronously, so it discards the very emission that raised the spinner and then waits for a + subsequent one. The last emission of a load burst re-raises the spinner with nothing left to + satisfy it, so on `/domain` it hangs until the 60 s deadline. Drop the `skip(1)`. +- `util/dispatchAfterPaint.ts` — `isPendingHeavyDispatch$` is only cleared from inside + `renderCodeMap$`. The blacklist actions that set it are not in `actionsRequiringRerender`, so the + clear happens only incidentally via `combineLatest` replay. A dispatch that does not change + accumulated data latches the flag true forever, with no deadline on that path at all. Give it a + clear that cannot outlive its own dispatch. + +Both need a regression test that fires *more than one* file-state emission after arming — the +existing spec only fires one, which is why it passes today. + +### 2. Introduce per-view readiness + +- Replace the single `isLoadingFile` boolean with readiness reported per view. The router URL is + already the source of truth for the active view (`routing/routePaths.ts`); there is no + `activeView` store to extend, so decide where readiness lives (a small store slice keyed by view + is likely cleanest, and keeps it testable without the router). +- Each view is ready when *its own* content has settled: Metrics when the map render has settled, + Domain when the word cloud and explorer have their data. +- The global overlay at `views/codeCharta.component.html:2` is replaced by one overlay per routed + view, rendered inside `metricsView` / `domainView`. Nav bar and view switcher stay interactive. +- Note `defaultIsLoadingFile = true` with `setState(defaultIsLoadingFile)` + (`isLoadingFile.reducer.ts:6`) means a dispatch with `value: undefined` latches the spinner **on**. + Do not carry that footgun into the replacement. + +### 3. Defer the map render until the Metrics view is shown + +- `KeepAliveRouteReuseStrategy` detaches rather than destroys the Metrics view, but + `RenderCodeMapEffect` is a global effect and keeps rendering while detached. Gate it so the heavy + render does not run while Metrics is off screen. +- On activation, run the pending render and show the Metrics spinner until it settles. +- Care: the reuse strategy exists because `CodeMapComponent.ngOnDestroy` tears down the Three.js + scene. Deferring must not reintroduce the empty-canvas-on-return problem, and must not leave the + scene stale after a load that happened while detached. + +### 4. Scope heavy dispatches to the active view + +- Explorer-initiated blacklist actions (`sidebarExplorer.write.store.ts:41,53`, + `nodeContextMenu.write.store.ts`, `blackListExtension.store.ts`) currently raise the one global + spinner. Route them through the active view's readiness instead: an exclude done from Domain + blocks only Domain; Metrics catches up on activation per Task 3. +- Same for `isApplyingScenario$` (`util/busy/isApplyingScenario.ts`), which is a module-level + subject deliberately outside the store — decide whether it stays that way under the new model. + +## Steps + +- [ ] Complete Task 1: Stop the current spinner from hanging — commit separately, tests first +- [ ] Complete Task 2: Introduce per-view readiness +- [ ] Complete Task 3: Defer the map render until the Metrics view is shown +- [ ] Complete Task 4: Scope heavy dispatches to the active view +- [ ] Update the e2e coverage (`e2e/loadPipeline.e2e.ts`, `e2e/domainView.e2e.ts`) for the split spinner +- [ ] Update CHANGELOG.md + +## Notes + +Decisions taken with the user on 2026-07-20: + +- **Map render**: defer until Metrics is shown. Domain loads stay cheap; the map cost is paid on + switch, where the Metrics spinner covers it. +- **Spinner scope**: one overlay per view (not per region). The explorer does not get its own + indicator for now. +- **Heavy ops**: scoped to the active view. +- **Delivery**: bugfix first, then the redesign. + +Findings from the investigation that shaped this: + +- The spinner is an OR of three sources — `isLoadingFile$`, `isPendingHeavyDispatch$`, + `isApplyingScenario$` (`loadingFileProgressSpinner.service.ts:14`). All three need a home in the + new model; fixing only the first leaves two ways to hang. +- The comment in `setLoadingIndicator.effect.ts:54-58` claiming the domain view produces no + `renderCodeMap$` is only true when deep-linking to `#/domain` without ever activating Metrics. + `RenderCodeMapEffect` is global and fires on every route today. Task 3 is what actually makes that + comment true. diff --git a/visualization/app/codeCharta/features/codeMap/effects/setLoadingIndicator/setLoadingIndicator.effect.spec.ts b/visualization/app/codeCharta/features/codeMap/effects/setLoadingIndicator/setLoadingIndicator.effect.spec.ts index b14c3e8b3a..af9243d609 100644 --- a/visualization/app/codeCharta/features/codeMap/effects/setLoadingIndicator/setLoadingIndicator.effect.spec.ts +++ b/visualization/app/codeCharta/features/codeMap/effects/setLoadingIndicator/setLoadingIndicator.effect.spec.ts @@ -1,16 +1,29 @@ import { TestBed } from "@angular/core/testing" +import { Router } from "@angular/router" import { EffectsModule } from "@ngrx/effects" import { provideMockActions } from "@ngrx/effects/testing" import { Action } from "@ngrx/store" import { MockStore, provideMockStore } from "@ngrx/store/testing" import { BehaviorSubject, Subject, Subscription } from "rxjs" -import { FileStoreReadWindow, setIsLoadingFile } from "../../../../stores/fileStore/fileStore.facade" +import { routeLinks } from "../../../../routing/routePaths" +import { FileStoreReadWindow, filesLoaded, setIsLoadingFile } from "../../../../stores/fileStore/fileStore.facade" import { visibleFileStatesSelector } from "../../../../stores/fileStore/store/visibleFileStates.selector" import { defaultState } from "../../../../stores/rootStore/state.manager" +import { NO_URL_METRICS } from "../../../../util/queryParameter/queryParameter" import { wait } from "../../../../util/testUtils/wait" import { maxFPS, RenderCodeMapEffect } from "../renderCodeMapEffect/renderCodeMap.effect" import { LOADING_INDICATOR_MAX_WAIT_MS, LOADING_INDICATOR_QUIET_PERIOD_MS, LoadingIndicatorEffect } from "./setLoadingIndicator.effect" +const filesLoadedAction = () => + filesLoaded({ + source: "upload", + areSampleFiles: false, + urlMetrics: NO_URL_METRICS, + forceAutoFit: false, + forceDefaultMetrics: false, + restoredSettings: null + }) + describe("LoadingIndicatorEffect", () => { let actions$: Subject let store: MockStore @@ -18,17 +31,20 @@ describe("LoadingIndicatorEffect", () => { let isLoadingFile$: BehaviorSubject let scannedActions: Action[] let subscription: Subscription + let router: { url: string } beforeEach(() => { actions$ = new Subject() mockedRenderCodeMap$ = new Subject() isLoadingFile$ = new BehaviorSubject(false) + router = { url: routeLinks.metrics } TestBed.configureTestingModule({ imports: [EffectsModule.forRoot([LoadingIndicatorEffect])], providers: [ { provide: RenderCodeMapEffect, useValue: { renderCodeMap$: mockedRenderCodeMap$ } }, { provide: FileStoreReadWindow, useValue: { isLoadingFile$ } }, + { provide: Router, useValue: router }, provideMockStore({ initialState: defaultState, selectors: [{ selector: visibleFileStatesSelector, value: [] }] @@ -81,6 +97,62 @@ describe("LoadingIndicatorEffect", () => { expect(scannedActions).toContainEqual(setIsLoadingFile({ value: false })) }) + it("should hide the loading indicator on a non-map route once the load commits and the file set settles", async () => { + // Arrange — a load raises the indicator before the files are even fetched, so it waits for the commit + router.url = routeLinks.domain + isLoadingFile$.next(true) + + // Act + actions$.next(filesLoadedAction()) + store.overrideSelector(visibleFileStatesSelector, [{}] as never) + store.refreshState() + await wait(LOADING_INDICATOR_QUIET_PERIOD_MS + maxFPS) + + // Assert + expect(scannedActions).toContainEqual(setIsLoadingFile({ value: false })) + }) + + it("should NOT hide the loading indicator on a non-map route while the load is still in flight", async () => { + // Arrange — no filesLoaded yet, so the files have not reached the store + router.url = routeLinks.domain + isLoadingFile$.next(true) + + // Act + await wait(LOADING_INDICATOR_QUIET_PERIOD_MS + maxFPS) + + // Assert — clearing here would drop the spinner mid-load + expect(scannedActions).not.toContainEqual(setIsLoadingFile({ value: false })) + }) + + it("should hide the loading indicator on a non-map route when the file-selection change is itself the last emission", async () => { + // Arrange — a file-panel change raises the indicator and nothing emits after it. Waiting for a + // SUBSEQUENT file-set emission would hang here until the max-wait deadline. + router.url = routeLinks.domain + + // Act + store.overrideSelector(visibleFileStatesSelector, [{}] as never) + store.refreshState() + isLoadingFile$.next(true) + await wait(LOADING_INDICATOR_QUIET_PERIOD_MS + maxFPS) + + // Assert + expect(scannedActions).toContainEqual(setIsLoadingFile({ value: false })) + }) + + it("should NOT hide on the map route from the file-set emission alone (it waits for the render)", async () => { + // Arrange — on the metrics route the spinner must wait for renderCodeMap$, not the file set + router.url = routeLinks.metrics + isLoadingFile$.next(true) + + // Act + store.overrideSelector(visibleFileStatesSelector, [{}] as never) + store.refreshState() + await wait(LOADING_INDICATOR_QUIET_PERIOD_MS + maxFPS) + + // Assert — no render happened, so it stays up + expect(scannedActions).not.toContainEqual(setIsLoadingFile({ value: false })) + }) + it("should not hide the loading indicator while it is already down", async () => { // Act — no load is in flight, so a render must not dispatch anything mockedRenderCodeMap$.next("") diff --git a/visualization/app/codeCharta/features/codeMap/effects/setLoadingIndicator/setLoadingIndicator.effect.ts b/visualization/app/codeCharta/features/codeMap/effects/setLoadingIndicator/setLoadingIndicator.effect.ts index 8bf8728e97..17ebf1fc12 100644 --- a/visualization/app/codeCharta/features/codeMap/effects/setLoadingIndicator/setLoadingIndicator.effect.ts +++ b/visualization/app/codeCharta/features/codeMap/effects/setLoadingIndicator/setLoadingIndicator.effect.ts @@ -1,9 +1,16 @@ import { Injectable } from "@angular/core" -import { createEffect } from "@ngrx/effects" +import { Router } from "@angular/router" +import { Actions, createEffect, ofType } from "@ngrx/effects" import { Store } from "@ngrx/store" -import { debounceTime, filter, map, race, skip, switchMap, take, timer } from "rxjs" +import { debounceTime, filter, map, NEVER, Observable, race, skip, switchMap, take, tap, timer } from "rxjs" import { CcState } from "../../../../model/codeCharta.model" -import { FileStoreReadWindow, setIsLoadingFile, visibleFileStatesSelector } from "../../../../stores/fileStore/fileStore.facade" +import { routeLinks } from "../../../../routing/routePaths" +import { + FileStoreReadWindow, + filesLoaded, + setIsLoadingFile, + visibleFileStatesSelector +} from "../../../../stores/fileStore/fileStore.facade" import { RenderCodeMapEffect } from "../renderCodeMapEffect/renderCodeMap.effect" export const LOADING_INDICATOR_QUIET_PERIOD_MS = 350 @@ -28,14 +35,27 @@ export const LOADING_INDICATOR_MAX_WAIT_MS = 60_000 export class LoadingIndicatorEffect { constructor( private readonly store: Store, + private readonly actions$: Actions, private readonly fileStoreReadWindow: FileStoreReadWindow, - private readonly renderCodeMapEffect: RenderCodeMapEffect + private readonly renderCodeMapEffect: RenderCodeMapEffect, + private readonly router: Router ) {} + /** + * Which raise armed the current wait. The two raisers need opposite settle conditions on a non-map + * route, and they dispatch the same action, so the distinction has to be carried here: a file-panel + * change has ALREADY landed when it raises the indicator, while a load raises it before the files + * have even been fetched. Cleared whenever the wait resolves. + */ + private armedByFileSelectionChange = false + /** A file-panel change (delta switch, file removal, re-selection) rebuilds the map too. */ showOnFileSelectionChange$ = createEffect(() => this.store.select(visibleFileStatesSelector).pipe( skip(1), + tap(() => { + this.armedByFileSelectionChange = true + }), map(() => setIsLoadingFile({ value: true })) ) ) @@ -48,11 +68,48 @@ export class LoadingIndicatorEffect { // Wait for the burst of late-arriving renders (blacklist apply, autoFit) to settle, // otherwise the user sees the map jump right after the spinner clears. this.renderCodeMapEffect.renderCodeMap$.pipe(debounceTime(LOADING_INDICATOR_QUIET_PERIOD_MS), take(1)), - // A load that produces no renderable map must not leave the spinner up forever. + this.nonMapViewSettled$(), + // A load that produces no renderable content at all must not leave the spinner up forever. timer(LOADING_INDICATOR_MAX_WAIT_MS) ) ), + tap(() => { + this.armedByFileSelectionChange = false + }), map(() => setIsLoadingFile({ value: false })) ) ) + + /** + * A non-map view (the domain word cloud) produces no renderCodeMap$, so waiting on the map render + * alone would leave the spinner up until the max-wait deadline. Clear once the file data has settled + * instead — the same quiet period gives the view time to render its own content. + */ + private nonMapViewSettled$(): Observable { + if (this.isOnMetricsRoute()) { + return NEVER + } + if (this.armedByFileSelectionChange) { + // The change is already in the store, so the CURRENT file set is the one to settle on. + // Waiting for a SUBSEQUENT emission would hang: the emission that raised the indicator is + // usually the last one there is. + return this.fileStatesSettled$() + } + // A load raised the indicator before the fetch, so the file set still holds the PREVIOUS files. + // Settling on it now would drop the spinner mid-load; wait for the commit first. + return this.actions$.pipe( + ofType(filesLoaded), + take(1), + switchMap(() => this.fileStatesSettled$()) + ) + } + + private fileStatesSettled$(): Observable { + return this.store.select(visibleFileStatesSelector).pipe(debounceTime(LOADING_INDICATOR_QUIET_PERIOD_MS), take(1)) + } + + /** The metrics (3D map) view is the only view that produces a renderable map. */ + private isOnMetricsRoute(): boolean { + return this.router.url.split("?")[0] === routeLinks.metrics + } } diff --git a/visualization/app/codeCharta/util/dispatchAfterPaint.spec.ts b/visualization/app/codeCharta/util/dispatchAfterPaint.spec.ts new file mode 100644 index 0000000000..c5fd064992 --- /dev/null +++ b/visualization/app/codeCharta/util/dispatchAfterPaint.spec.ts @@ -0,0 +1,77 @@ +import { Action, Store } from "@ngrx/store" +import { CcState } from "../model/codeCharta.model" +import { clearPendingHeavyDispatch, dispatchAfterPaint, HEAVY_DISPATCH_MAX_WAIT_MS, isPendingHeavyDispatch$ } from "./dispatchAfterPaint" + +const anAction: Action = { type: "AN_ACTION" } + +describe("dispatchAfterPaint", () => { + let store: Store + let originalRequestAnimationFrame: typeof globalThis.requestAnimationFrame + + beforeEach(() => { + store = { dispatch: jest.fn() } as unknown as Store + clearPendingHeavyDispatch() + + // The production path is skipped in tests by default, but it is exactly what is under test here. + globalThis["__TEST_ENVIRONMENT__"] = false + // Fake timers stub requestAnimationFrame too, so the paint deferral has to be replaced AFTER + // they are installed. Running the callback synchronously keeps the tests about the backstop. + jest.useFakeTimers() + originalRequestAnimationFrame = globalThis.requestAnimationFrame + globalThis.requestAnimationFrame = (callback => { + callback(0) + return 0 + }) as typeof globalThis.requestAnimationFrame + }) + + afterEach(() => { + jest.useRealTimers() + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis["__TEST_ENVIRONMENT__"] = true + clearPendingHeavyDispatch() + }) + + it("should show the spinner until the dispatched action has been processed", () => { + // Act + dispatchAfterPaint(store, anAction) + + // Assert + expect(store.dispatch).toHaveBeenCalledWith(anAction) + expect(isPendingHeavyDispatch$.value).toBe(true) + }) + + it("should clear the pending heavy dispatch when no render follows", () => { + // Arrange — a dispatch that changes nothing produces no render, and the render is the only + // thing that normally clears the flag. Without a backstop the spinner would stay up forever. + dispatchAfterPaint(store, anAction) + + // Act + jest.advanceTimersByTime(HEAVY_DISPATCH_MAX_WAIT_MS + 1) + + // Assert + expect(isPendingHeavyDispatch$.value).toBe(false) + }) + + it("should not clear the pending heavy dispatch before the backstop deadline", () => { + // Arrange + dispatchAfterPaint(store, anAction) + + // Act + jest.advanceTimersByTime(HEAVY_DISPATCH_MAX_WAIT_MS - 1) + + // Assert + expect(isPendingHeavyDispatch$.value).toBe(true) + }) + + it("should not resurrect the spinner when a render already cleared it", () => { + // Arrange + dispatchAfterPaint(store, anAction) + clearPendingHeavyDispatch() + + // Act + jest.advanceTimersByTime(HEAVY_DISPATCH_MAX_WAIT_MS + 1) + + // Assert + expect(isPendingHeavyDispatch$.value).toBe(false) + }) +}) diff --git a/visualization/app/codeCharta/util/dispatchAfterPaint.ts b/visualization/app/codeCharta/util/dispatchAfterPaint.ts index 24e58f34fa..d1953d9291 100644 --- a/visualization/app/codeCharta/util/dispatchAfterPaint.ts +++ b/visualization/app/codeCharta/util/dispatchAfterPaint.ts @@ -7,7 +7,23 @@ import { CcState } from "../model/codeCharta.model" // by `renderCodeMap$` once the resulting render has finished. export const isPendingHeavyDispatch$ = new BehaviorSubject(false) +/** + * The backstop for a dispatch that never renders. `renderCodeMap$` is the normal clear, but it only + * runs when the dispatched action actually changes the accumulated data — a no-op exclude leaves + * nothing to render, and without this the spinner would stay up for the rest of the session. + * + * A genuinely slow render blocks the main thread, so this timer cannot fire mid-render and cut a real + * one short: it lands after the render has already cleared the flag, where it is a no-op. + */ +export const HEAVY_DISPATCH_MAX_WAIT_MS = 2_000 + +let pendingBackstop: ReturnType | undefined + export function clearPendingHeavyDispatch(): void { + if (pendingBackstop !== undefined) { + clearTimeout(pendingBackstop) + pendingBackstop = undefined + } if (isPendingHeavyDispatch$.value) { isPendingHeavyDispatch$.next(false) } @@ -34,6 +50,7 @@ export function dispatchAfterPaint(store: Store, action: Action | Actio for (const a of actions) { store.dispatch(a) } + pendingBackstop = setTimeout(clearPendingHeavyDispatch, HEAVY_DISPATCH_MAX_WAIT_MS) }) }) } From 91b940f8cd735cdba4f4748d81d63218ae8c8e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20H=C3=BChn?= Date: Mon, 20 Jul 2026 13:51:50 +0200 Subject: [PATCH 03/52] refactor(visualization): extract shared bar UI kit from metricsBar Move axisCard, sliderNumberInput, settingsPopoverShell and the settingsInput helpers out of metricsBar into features/shared so a second settings bar can compose them, and replace the metricsBar host chrome with a reusable BarShellDirective. Pure structural change: the vertical offset stays with each bar via the BAR_BOTTOM_* constants. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../areaSettingsPopover.component.ts | 4 +-- .../colorSegment/colorSegment.component.ts | 2 +- .../colorRangeSection.component.ts | 2 +- .../colorSettingsPopover.component.ts | 2 +- .../metricColorRangeSlider.component.ts | 2 +- .../edgeSegment/edgeSegment.component.ts | 2 +- .../edgeSettingsPopover.component.ts | 9 +++-- .../heightSettingsPopover.component.ts | 4 +-- .../labelsScenariosSegment.component.ts | 2 +- .../metricSegment/metricSegment.component.ts | 2 +- .../metricSelectPopover.component.ts | 2 +- .../metricsBar/metricsBar.component.ts | 14 +++----- .../components/metricsBar/metricsBar.e2e.ts | 14 ++++---- .../components/metricsBar/metricsBar.po.ts | 29 +++++++++++++--- .../settingsPopoverShell.component.html | 11 ------ .../axisCard/axisCard.component.html | 0 .../axisCard/axisCard.component.spec.ts | 0 .../components/axisCard/axisCard.component.ts | 0 .../axisCard/axisCardHeader.component.html | 0 .../axisCard/axisCardHeader.component.ts | 0 .../components/barShell/barShell.directive.ts | 34 +++++++++++++++++++ .../settingsPopoverShell.component.html | 19 +++++++++++ .../settingsPopoverShell.component.spec.ts | 31 +++++++++++++++++ .../settingsPopoverShell.component.ts | 15 ++++++-- .../sliderNumberInput.component.html | 0 .../sliderNumberInput.component.spec.ts | 0 .../sliderNumberInput.component.ts | 0 .../app/codeCharta/features/shared/facade.ts | 9 +++++ .../util/settingsInput.ts | 0 29 files changed, 160 insertions(+), 49 deletions(-) delete mode 100644 visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.html rename visualization/app/codeCharta/features/{metricsBar => shared}/components/axisCard/axisCard.component.html (100%) rename visualization/app/codeCharta/features/{metricsBar => shared}/components/axisCard/axisCard.component.spec.ts (100%) rename visualization/app/codeCharta/features/{metricsBar => shared}/components/axisCard/axisCard.component.ts (100%) rename visualization/app/codeCharta/features/{metricsBar => shared}/components/axisCard/axisCardHeader.component.html (100%) rename visualization/app/codeCharta/features/{metricsBar => shared}/components/axisCard/axisCardHeader.component.ts (100%) create mode 100644 visualization/app/codeCharta/features/shared/components/barShell/barShell.directive.ts create mode 100644 visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.html rename visualization/app/codeCharta/features/{metricsBar => shared}/components/settingsPopoverShell/settingsPopoverShell.component.spec.ts (71%) rename visualization/app/codeCharta/features/{metricsBar => shared}/components/settingsPopoverShell/settingsPopoverShell.component.ts (73%) rename visualization/app/codeCharta/features/{metricsBar => shared}/components/sliderNumberInput/sliderNumberInput.component.html (100%) rename visualization/app/codeCharta/features/{metricsBar => shared}/components/sliderNumberInput/sliderNumberInput.component.spec.ts (100%) rename visualization/app/codeCharta/features/{metricsBar => shared}/components/sliderNumberInput/sliderNumberInput.component.ts (100%) rename visualization/app/codeCharta/features/{metricsBar => shared}/util/settingsInput.ts (100%) diff --git a/visualization/app/codeCharta/features/metricsBar/components/areaSettingsPopover/areaSettingsPopover.component.ts b/visualization/app/codeCharta/features/metricsBar/components/areaSettingsPopover/areaSettingsPopover.component.ts index a366aafa38..4ff4358cfd 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/areaSettingsPopover/areaSettingsPopover.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/areaSettingsPopover/areaSettingsPopover.component.ts @@ -1,10 +1,8 @@ import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core" import { toSignal } from "@angular/core/rxjs-interop" import { MapStateReadWindow } from "../../../../stores/mapState/mapState.read.facade" -import { ResetSettingsButtonComponent } from "../../../shared/facade" +import { ResetSettingsButtonComponent, SettingsPopoverShellComponent, SliderNumberInputComponent } from "../../../shared/facade" import { MetricsBarWriteStore } from "../../stores/metricsBar.write.store" -import { SettingsPopoverShellComponent } from "../settingsPopoverShell/settingsPopoverShell.component" -import { SliderNumberInputComponent } from "../sliderNumberInput/sliderNumberInput.component" @Component({ selector: "cc-area-settings-popover", diff --git a/visualization/app/codeCharta/features/metricsBar/components/colorSegment/colorSegment.component.ts b/visualization/app/codeCharta/features/metricsBar/components/colorSegment/colorSegment.component.ts index e47ee961f6..51fcbdcc4b 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/colorSegment/colorSegment.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/colorSegment/colorSegment.component.ts @@ -2,8 +2,8 @@ import { ChangeDetectionStrategy, Component } from "@angular/core" import { toSignal } from "@angular/core/rxjs-interop" import { MapStateReadWindow } from "../../../../stores/mapState/mapState.read.facade" import { PreferencesReadWindow } from "../../../../stores/preferences/preferences.read.facade" +import { AxisCardComponent } from "../../../shared/facade" import { MetricsBarWriteStore } from "../../stores/metricsBar.write.store" -import { AxisCardComponent } from "../axisCard/axisCard.component" import { ColorSettingsPopoverComponent } from "../colorSettingsPopover/colorSettingsPopover.component" import { MetricMetaValueComponent } from "../metricMetaValue/metricMetaValue.component" import { MetricSelectPopoverComponent } from "../metricSelectPopover/metricSelectPopover.component" diff --git a/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/colorRangeSection.component.ts b/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/colorRangeSection.component.ts index c17a88f939..2eb80752e8 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/colorRangeSection.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/colorRangeSection.component.ts @@ -5,9 +5,9 @@ import { MetricsLensFacade } from "../../../../lenses/metrics/metricsLens.facade import { ColorRange } from "../../../../model/codeCharta.model" import { MapStateReadWindow } from "../../../../stores/mapState/mapState.read.facade" import { debounce } from "../../../../util/debounce" +import { SETTINGS_INPUT_DEBOUNCE_MS } from "../../../shared/facade" import { MetricsBarReadStore } from "../../stores/metricsBar.read.store" import { MetricsBarWriteStore } from "../../stores/metricsBar.write.store" -import { SETTINGS_INPUT_DEBOUNCE_MS } from "../../util/settingsInput" import { MetricColorRangeDiagramComponent } from "./metricColorRangeDiagram.component" import { HandleValueChange, MetricColorRangeSliderComponent } from "./metricColorRangeSlider.component" diff --git a/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/colorSettingsPopover.component.ts b/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/colorSettingsPopover.component.ts index 1cc3377274..3852b668f0 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/colorSettingsPopover.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/colorSettingsPopover.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, input } from "@angular/co import { toSignal } from "@angular/core/rxjs-interop" import { FileStoreReadWindow } from "../../../../stores/fileStore/fileStore.facade" import { MapStateReadWindow } from "../../../../stores/mapState/mapState.read.facade" -import { SettingsPopoverShellComponent } from "../settingsPopoverShell/settingsPopoverShell.component" +import { SettingsPopoverShellComponent } from "../../../shared/facade" import { ColorBandsSectionComponent } from "./colorBandsSection.component" import { ColorRangeSectionComponent } from "./colorRangeSection.component" import { ColorSettingsHeaderComponent } from "./colorSettingsHeader.component" diff --git a/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/metricColorRangeSlider.component.ts b/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/metricColorRangeSlider.component.ts index 42083b1a77..bf500df7fa 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/metricColorRangeSlider.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/colorSettingsPopover/metricColorRangeSlider.component.ts @@ -10,7 +10,7 @@ import { SimpleChanges, ViewChild } from "@angular/core" -import { parseChangedNumberInput } from "../../util/settingsInput" +import { parseChangedNumberInput } from "../../../shared/facade" import { RangeSliderLabelsComponent } from "./rangeSliderLabels.component" import { calculateSliderRangePosition, diff --git a/visualization/app/codeCharta/features/metricsBar/components/edgeSegment/edgeSegment.component.ts b/visualization/app/codeCharta/features/metricsBar/components/edgeSegment/edgeSegment.component.ts index d718875862..2cd89f5c3e 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/edgeSegment/edgeSegment.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/edgeSegment/edgeSegment.component.ts @@ -1,9 +1,9 @@ import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core" import { toSignal } from "@angular/core/rxjs-interop" import { MapStateReadWindow } from "../../../../stores/mapState/mapState.read.facade" +import { AxisCardComponent } from "../../../shared/facade" import { NodeSelectionService } from "../../services/nodeSelection.service" import { MetricsBarWriteStore } from "../../stores/metricsBar.write.store" -import { AxisCardComponent } from "../axisCard/axisCard.component" import { EdgeSettingsPopoverComponent } from "../edgeSettingsPopover/edgeSettingsPopover.component" import { MetricChooserTypeComponent } from "../metricMetaValue/metricChooserType.component" import { MetricSelectPopoverComponent } from "../metricSelectPopover/metricSelectPopover.component" diff --git a/visualization/app/codeCharta/features/metricsBar/components/edgeSettingsPopover/edgeSettingsPopover.component.ts b/visualization/app/codeCharta/features/metricsBar/components/edgeSettingsPopover/edgeSettingsPopover.component.ts index 1fc49fc5e2..2e94d482dc 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/edgeSettingsPopover/edgeSettingsPopover.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/edgeSettingsPopover/edgeSettingsPopover.component.ts @@ -3,11 +3,14 @@ import { toSignal } from "@angular/core/rxjs-interop" import { map } from "rxjs" import { HexMapColor } from "../../../../model/codeCharta.model" import { defaultMapColors, MapStateReadWindow } from "../../../../stores/mapState/mapState.read.facade" -import { InlineColorPickerComponent, ResetSettingsButtonComponent } from "../../../shared/facade" +import { + InlineColorPickerComponent, + ResetSettingsButtonComponent, + SettingsPopoverShellComponent, + SliderNumberInputComponent +} from "../../../shared/facade" import { MetricsBarReadStore } from "../../stores/metricsBar.read.store" import { MetricsBarWriteStore } from "../../stores/metricsBar.write.store" -import { SettingsPopoverShellComponent } from "../settingsPopoverShell/settingsPopoverShell.component" -import { SliderNumberInputComponent } from "../sliderNumberInput/sliderNumberInput.component" import { EdgeMetricToggleComponent } from "./edgeMetricToggle.component" @Component({ diff --git a/visualization/app/codeCharta/features/metricsBar/components/heightSettingsPopover/heightSettingsPopover.component.ts b/visualization/app/codeCharta/features/metricsBar/components/heightSettingsPopover/heightSettingsPopover.component.ts index 0794cbac53..ff4bbab056 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/heightSettingsPopover/heightSettingsPopover.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/heightSettingsPopover/heightSettingsPopover.component.ts @@ -2,10 +2,8 @@ import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core import { toSignal } from "@angular/core/rxjs-interop" import { FileStoreReadWindow } from "../../../../stores/fileStore/fileStore.facade" import { MapStateReadWindow } from "../../../../stores/mapState/mapState.read.facade" -import { ResetSettingsButtonComponent } from "../../../shared/facade" +import { ResetSettingsButtonComponent, SettingsPopoverShellComponent, SliderNumberInputComponent } from "../../../shared/facade" import { MetricsBarWriteStore } from "../../stores/metricsBar.write.store" -import { SettingsPopoverShellComponent } from "../settingsPopoverShell/settingsPopoverShell.component" -import { SliderNumberInputComponent } from "../sliderNumberInput/sliderNumberInput.component" @Component({ selector: "cc-height-settings-popover", diff --git a/visualization/app/codeCharta/features/metricsBar/components/labelsScenariosSegment/labelsScenariosSegment.component.ts b/visualization/app/codeCharta/features/metricsBar/components/labelsScenariosSegment/labelsScenariosSegment.component.ts index f7c4a2b9cb..051740f0bd 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/labelsScenariosSegment/labelsScenariosSegment.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/labelsScenariosSegment/labelsScenariosSegment.component.ts @@ -9,7 +9,7 @@ import { ScenarioListDialogComponent, ScenariosService } from "../../../scenarios/facade" -import { SettingsPopoverShellComponent } from "../settingsPopoverShell/settingsPopoverShell.component" +import { SettingsPopoverShellComponent } from "../../../shared/facade" @Component({ selector: "cc-labels-scenarios-segment", diff --git a/visualization/app/codeCharta/features/metricsBar/components/metricSegment/metricSegment.component.ts b/visualization/app/codeCharta/features/metricsBar/components/metricSegment/metricSegment.component.ts index 4d7dfd3ab8..64a42b50ab 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/metricSegment/metricSegment.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/metricSegment/metricSegment.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, input, output } from "@angular/core" import { PrimaryMetrics } from "../../../../model/codeCharta.model" -import { AxisCardComponent } from "../axisCard/axisCard.component" +import { AxisCardComponent } from "../../../shared/facade" import { MetricMetaValueComponent } from "../metricMetaValue/metricMetaValue.component" import { MetricSelectPopoverComponent } from "../metricSelectPopover/metricSelectPopover.component" diff --git a/visualization/app/codeCharta/features/metricsBar/components/metricSelectPopover/metricSelectPopover.component.ts b/visualization/app/codeCharta/features/metricsBar/components/metricSelectPopover/metricSelectPopover.component.ts index d1e7a8f2fe..78e44d8c86 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/metricSelectPopover/metricSelectPopover.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/metricSelectPopover/metricSelectPopover.component.ts @@ -15,8 +15,8 @@ import { toSignal } from "@angular/core/rxjs-interop" import { FormsModule } from "@angular/forms" import { MetricsLensFacade } from "../../../../lenses/metrics/metricsLens.facade" import { EdgeMetricData, NodeMetricData } from "../../../../model/codeCharta.model" +import { SettingsPopoverShellComponent } from "../../../shared/facade" import { MetricsBarReadStore } from "../../stores/metricsBar.read.store" -import { SettingsPopoverShellComponent } from "../settingsPopoverShell/settingsPopoverShell.component" import { FilterMetricDataBySearchTermPipe } from "./filterMetricDataBySearchTerm.pipe" import { MetricSelectOptionComponent } from "./metricSelectOption.component" diff --git a/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.component.ts b/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.component.ts index 0d851bd4f1..2bcaf4f0e3 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.component.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.component.ts @@ -2,9 +2,9 @@ import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/c import { toSignal } from "@angular/core/rxjs-interop" import { map } from "rxjs" import { FileStoreReadWindow } from "../../../../stores/fileStore/fileStore.facade" +import { AxisCardComponent, BAR_BOTTOM_ABOVE_FILE_EXTENSION_BAR, BarShellDirective } from "../../../shared/facade" import { MetricsBarReadStore } from "../../stores/metricsBar.read.store" import { AreaSegmentComponent } from "../areaSegment/areaSegment.component" -import { AxisCardComponent } from "../axisCard/axisCard.component" import { ColorSegmentComponent } from "../colorSegment/colorSegment.component" import { ColorSettingsPopoverComponent } from "../colorSettingsPopover/colorSettingsPopover.component" import { EdgeSegmentComponent } from "../edgeSegment/edgeSegment.component" @@ -26,16 +26,12 @@ import { LinkColorHeightButtonComponent } from "../linkColorHeightButton/linkCol LabelsScenariosSegmentComponent, LinkColorHeightButtonComponent ], - host: { - class: "fixed left-0 right-0 mx-auto flex bg-base-100 rounded-box shadow-lg border border-base-300", - "[style.bottom]": "'calc(var(--cc-bottom-bar-height, 32px) + var(--cc-file-extension-bar-height, 17px) + 12px)'", - "[style.width]": "'max-content'", - "[style.maxWidth]": "'min(95vw, 1200px)'", - "[style.zIndex]": "50", - "[style.pointerEvents]": "'auto'" - } + hostDirectives: [BarShellDirective], + host: { "[style.bottom]": "barBottom" } }) export class MetricsBarComponent { + readonly barBottom = BAR_BOTTOM_ABOVE_FILE_EXTENSION_BAR + private readonly fileStoreReadWindow = inject(FileStoreReadWindow) private readonly metricsBarReadStore = inject(MetricsBarReadStore) diff --git a/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.e2e.ts b/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.e2e.ts index 5017fb3096..001ac636bf 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.e2e.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.e2e.ts @@ -33,11 +33,13 @@ test.describe("MetricsBar", () => { await metricsBar.openAreaMetricSelect() await metricsBar.searchAreaMetric("functions") - const options = await metricsBar.getAreaMetricOptionNames() - expect(options.length).toBeGreaterThanOrEqual(1) - for (const option of options) { - expect(option.toLowerCase()).toContain("functions") - } + // Filtering re-renders the option list asynchronously, so poll instead of reading once + await expect + .poll(async () => { + const options = await metricsBar.getAreaMetricOptionNames() + return options.length > 0 && options.every(option => option.toLowerCase().includes("functions")) + }) + .toBe(true) }) test("should update the area segment when selecting a different metric", async ({ page }) => { @@ -52,6 +54,6 @@ test.describe("MetricsBar", () => { await metricsBar.selectAreaMetricOption(otherMetric as string) - expect(await metricsBar.getSelectedAreaMetricName()).toBe(otherMetric) + await expect(metricsBar.selectedAreaMetricName()).toHaveText(otherMetric as string) }) }) diff --git a/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.po.ts b/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.po.ts index 10d138d0c8..03ea0129e8 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.po.ts +++ b/visualization/app/codeCharta/features/metricsBar/components/metricsBar/metricsBar.po.ts @@ -1,4 +1,4 @@ -import { Page } from "@playwright/test" +import { Locator, Page } from "@playwright/test" import { clickButtonOnPageElement } from "../../../../../playwright.helper" export class MetricsBarPageObject { @@ -11,10 +11,23 @@ export class MetricsBarPageObject { async openAreaMetricSelect() { await clickButtonOnPageElement(this.page, `button[popovertarget='${this.areaSearchPopoverId}']`) await this.page.locator(`[data-testid='${this.areaPopoverTestId}']`).waitFor({ state: "visible", timeout: 10_000 }) + // The popover's "toggle" handler clears the search term and then focuses the input, and it runs + // after the popover becomes visible. Typing before it lands would have the reset overwrite the + // term, so wait for the focus that marks the handler as done. + await this.areaMetricSearchInput().waitFor({ state: "visible", timeout: 10_000 }) + await this.page.waitForFunction( + selector => document.activeElement === document.querySelector(selector), + `[data-testid='${this.areaPopoverTestId}'] input[type='text']`, + { timeout: 10_000 } + ) + } + + areaMetricSearchInput(): Locator { + return this.page.locator(`[data-testid='${this.areaPopoverTestId}'] input[type='text']`) } async searchAreaMetric(term: string) { - await this.page.locator(`[data-testid='${this.areaPopoverTestId}'] input[type='text']`).fill(term) + await this.areaMetricSearchInput().fill(term) } async getAreaMetricOptionNames() { @@ -27,9 +40,17 @@ export class MetricsBarPageObject { await this.page.locator(`[data-testid='${this.areaPopoverTestId}'] button[data-metric-name='${metricName}']`).click() } + /** + * The element showing the area segment's selected metric. Prefer this with a web-first assertion + * (`await expect(po.selectedAreaMetricName()).toHaveText(…)`) over reading the text: selecting a + * metric dispatches a store update that lands a tick after the click, so a plain read races it. + */ + selectedAreaMetricName(): Locator { + return this.page.locator(`[data-testid='${this.areaSegmentTestId}'] .text-sm.font-semibold`).first() + } + async getSelectedAreaMetricName() { - const segment = this.page.locator(`[data-testid='${this.areaSegmentTestId}']`) - const text = await segment.locator(".text-sm.font-semibold").first().innerText() + const text = await this.selectedAreaMetricName().innerText() return text.trim() } } diff --git a/visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.html b/visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.html deleted file mode 100644 index fae784ad95..0000000000 --- a/visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- -
diff --git a/visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCard.component.html b/visualization/app/codeCharta/features/shared/components/axisCard/axisCard.component.html similarity index 100% rename from visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCard.component.html rename to visualization/app/codeCharta/features/shared/components/axisCard/axisCard.component.html diff --git a/visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCard.component.spec.ts b/visualization/app/codeCharta/features/shared/components/axisCard/axisCard.component.spec.ts similarity index 100% rename from visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCard.component.spec.ts rename to visualization/app/codeCharta/features/shared/components/axisCard/axisCard.component.spec.ts diff --git a/visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCard.component.ts b/visualization/app/codeCharta/features/shared/components/axisCard/axisCard.component.ts similarity index 100% rename from visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCard.component.ts rename to visualization/app/codeCharta/features/shared/components/axisCard/axisCard.component.ts diff --git a/visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCardHeader.component.html b/visualization/app/codeCharta/features/shared/components/axisCard/axisCardHeader.component.html similarity index 100% rename from visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCardHeader.component.html rename to visualization/app/codeCharta/features/shared/components/axisCard/axisCardHeader.component.html diff --git a/visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCardHeader.component.ts b/visualization/app/codeCharta/features/shared/components/axisCard/axisCardHeader.component.ts similarity index 100% rename from visualization/app/codeCharta/features/metricsBar/components/axisCard/axisCardHeader.component.ts rename to visualization/app/codeCharta/features/shared/components/axisCard/axisCardHeader.component.ts diff --git a/visualization/app/codeCharta/features/shared/components/barShell/barShell.directive.ts b/visualization/app/codeCharta/features/shared/components/barShell/barShell.directive.ts new file mode 100644 index 0000000000..1679b82886 --- /dev/null +++ b/visualization/app/codeCharta/features/shared/components/barShell/barShell.directive.ts @@ -0,0 +1,34 @@ +import { Directive } from "@angular/core" + +/** Gap (px) between a floating bar and whatever sits below it. */ +const BAR_GAP_PX = 12 + +/** + * Bottom offset for a floating bar in a view that mounts no file-extension bar (e.g. the domain view). + * Clears the bottom bar (and its selected-path breadcrumb) instead of straddling it. + */ +export const BAR_BOTTOM_ABOVE_BOTTOM_BAR = `calc(var(--cc-bottom-bar-height, 32px) + ${BAR_GAP_PX}px)` + +/** Bottom offset for a floating bar in a view that also mounts the file-extension bar (e.g. the map view). */ +export const BAR_BOTTOM_ABOVE_FILE_EXTENSION_BAR = `calc(var(--cc-bottom-bar-height, 32px) + var(--cc-file-extension-bar-height, 17px) + ${BAR_GAP_PX}px)` + +/** + * Shared chrome for the floating settings bars (metricsBar, domainBar): horizontally centered card + * pinned to the viewport bottom, sized to its content. + * + * Apply via `hostDirectives` so the chrome lands on the bar's own host element — no wrapper element, + * so host-element selectors (e.g. the screenshot service hiding `cc-metrics-bar`) keep working. + * The vertical offset stays with each bar, since it depends on which other bars that view mounts; + * bind `[style.bottom]` to one of the BAR_BOTTOM_* constants above. + */ +@Directive({ + selector: "[ccBarShell]", + host: { + class: "fixed left-0 right-0 mx-auto flex items-stretch bg-base-100 rounded-box shadow-lg border border-base-300", + "[style.width]": "'max-content'", + "[style.maxWidth]": "'min(95vw, 1200px)'", + "[style.zIndex]": "50", + "[style.pointerEvents]": "'auto'" + } +}) +export class BarShellDirective {} diff --git a/visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.html b/visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.html new file mode 100644 index 0000000000..debb9d9a52 --- /dev/null +++ b/visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.html @@ -0,0 +1,19 @@ +
+ +
diff --git a/visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.spec.ts b/visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.spec.ts similarity index 71% rename from visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.spec.ts rename to visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.spec.ts index f6e62039cc..d922680cef 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.spec.ts +++ b/visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.spec.ts @@ -70,6 +70,37 @@ describe("SettingsPopoverShellComponent", () => { expect(shell.className).toContain("w-80") }) + it("should cap its height and scroll so tall content degrades instead of clipping off-viewport", async () => { + // Arrange & Act + const { shell } = await setup() + + // Assert + expect(shell.className).toContain("overflow-y-auto") + expect(shell.className).toContain("max-h-[calc(100vh-5rem)]") + }) + + it("should expose a dialog role with a fallback accessible name when no heading id is given", async () => { + // Arrange & Act + const { shell } = await setup() + + // Assert + expect(shell.getAttribute("role")).toBe("dialog") + expect(shell.getAttribute("aria-label")).toBe("Settings") + expect(shell.hasAttribute("aria-labelledby")).toBe(false) + }) + + it("should label itself by the projected heading when ariaLabelledBy is provided", async () => { + // Arrange & Act + const renderResult = await render(SettingsPopoverShellComponent, { + inputs: { popoverId: "id", anchorName: "anchor", ariaLabelledBy: "word-cloud-settings-heading" } + }) + const shell = renderResult.container.querySelector("[popover]") as HTMLElement + + // Assert + expect(shell.getAttribute("aria-labelledby")).toBe("word-cloud-settings-heading") + expect(shell.hasAttribute("aria-label")).toBe(false) + }) + it("should not render a data-testid attribute when no testId is provided", async () => { // Arrange & Act const { shell } = await setup() diff --git a/visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.ts b/visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.ts similarity index 73% rename from visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.ts rename to visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.ts index e07524ecdc..4560ab0385 100644 --- a/visualization/app/codeCharta/features/metricsBar/components/settingsPopoverShell/settingsPopoverShell.component.ts +++ b/visualization/app/codeCharta/features/shared/components/settingsPopoverShell/settingsPopoverShell.component.ts @@ -1,5 +1,7 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, input, OnDestroy, viewChild } from "@angular/core" +const VIEWPORT_PADDING = 8 + @Component({ selector: "cc-settings-popover-shell", templateUrl: "./settingsPopoverShell.component.html", @@ -13,6 +15,13 @@ export class SettingsPopoverShellComponent implements AfterViewInit, OnDestroy { readonly contentClass = input("gap-2.5 py-2 px-5") readonly testId = input(null) + /** + * The shell is a `role="dialog"`, so it needs an accessible name. Consumers that project a heading pass + * its id via `ariaLabelledBy`; the rest fall back to a generic name rather than opening unannounced. + */ + readonly ariaLabelledBy = input(null) + readonly ariaLabel = input("Settings") + readonly popover = viewChild.required>("popover") // Fallback for browsers without CSS Anchor Positioning (e.g. Firefox): position the @@ -40,13 +49,15 @@ export class SettingsPopoverShellComponent implements AfterViewInit, OnDestroy { const popover = this.popover().nativeElement const anchorRect = anchor.getBoundingClientRect() const popoverWidth = popover.getBoundingClientRect().width - const viewportPadding = 8 - const left = Math.max(viewportPadding, Math.min(anchorRect.left, window.innerWidth - popoverWidth - viewportPadding)) + const left = Math.max(VIEWPORT_PADDING, Math.min(anchorRect.left, window.innerWidth - popoverWidth - VIEWPORT_PADDING)) popover.style.position = "fixed" popover.style.margin = "0" popover.style.left = `${left}px` popover.style.top = "auto" popover.style.bottom = `${window.innerHeight - anchorRect.top}px` + // Pinning only the bottom lets a tall popover grow off the top of the viewport, where it cannot be + // scrolled back into reach; cap it at the space actually above the anchor and let the shell scroll. + popover.style.maxHeight = `${Math.max(0, anchorRect.top - VIEWPORT_PADDING)}px` } } diff --git a/visualization/app/codeCharta/features/metricsBar/components/sliderNumberInput/sliderNumberInput.component.html b/visualization/app/codeCharta/features/shared/components/sliderNumberInput/sliderNumberInput.component.html similarity index 100% rename from visualization/app/codeCharta/features/metricsBar/components/sliderNumberInput/sliderNumberInput.component.html rename to visualization/app/codeCharta/features/shared/components/sliderNumberInput/sliderNumberInput.component.html diff --git a/visualization/app/codeCharta/features/metricsBar/components/sliderNumberInput/sliderNumberInput.component.spec.ts b/visualization/app/codeCharta/features/shared/components/sliderNumberInput/sliderNumberInput.component.spec.ts similarity index 100% rename from visualization/app/codeCharta/features/metricsBar/components/sliderNumberInput/sliderNumberInput.component.spec.ts rename to visualization/app/codeCharta/features/shared/components/sliderNumberInput/sliderNumberInput.component.spec.ts diff --git a/visualization/app/codeCharta/features/metricsBar/components/sliderNumberInput/sliderNumberInput.component.ts b/visualization/app/codeCharta/features/shared/components/sliderNumberInput/sliderNumberInput.component.ts similarity index 100% rename from visualization/app/codeCharta/features/metricsBar/components/sliderNumberInput/sliderNumberInput.component.ts rename to visualization/app/codeCharta/features/shared/components/sliderNumberInput/sliderNumberInput.component.ts diff --git a/visualization/app/codeCharta/features/shared/facade.ts b/visualization/app/codeCharta/features/shared/facade.ts index 8d87af8ae9..d416e36196 100644 --- a/visualization/app/codeCharta/features/shared/facade.ts +++ b/visualization/app/codeCharta/features/shared/facade.ts @@ -1,8 +1,17 @@ // Reusable presentational UI kit — the public surface other features compose in their templates. export { ActionIconComponent } from "./components/actionIcon/actionIcon.component" +export { AxisCardComponent } from "./components/axisCard/axisCard.component" +export { + BAR_BOTTOM_ABOVE_BOTTOM_BAR, + BAR_BOTTOM_ABOVE_FILE_EXTENSION_BAR, + BarShellDirective +} from "./components/barShell/barShell.directive" export { ErrorDialogComponent } from "./components/errorDialog/errorDialog.component" export { InlineColorPickerComponent } from "./components/inlineColorPicker/inlineColorPicker.component" export { LoadingFileProgressSpinnerComponent } from "./components/loadingFileProgressSpinner/loadingFileProgressSpinner.component" export { ResetSettingsButtonComponent } from "./components/resetSettingsButton/resetSettingsButton.component" +export { SettingsPopoverShellComponent } from "./components/settingsPopoverShell/settingsPopoverShell.component" +export { SliderNumberInputComponent } from "./components/sliderNumberInput/sliderNumberInput.component" export { BlacklistExclusionGuard } from "./effects/addBlacklistItemsIfNotResultsInEmptyMap/blacklistExclusionGuard" export { getPartialDefaultState } from "./getPartialDefaultState" +export { parseChangedNumberInput, SETTINGS_INPUT_DEBOUNCE_MS } from "./util/settingsInput" diff --git a/visualization/app/codeCharta/features/metricsBar/util/settingsInput.ts b/visualization/app/codeCharta/features/shared/util/settingsInput.ts similarity index 100% rename from visualization/app/codeCharta/features/metricsBar/util/settingsInput.ts rename to visualization/app/codeCharta/features/shared/util/settingsInput.ts From d5ce05203885e1da754ececfacc02edf90964264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20H=C3=BChn?= Date: Mon, 20 Jul 2026 13:52:39 +0200 Subject: [PATCH 04/52] feat(visualization): add domain lens data layer Load the cc.json 2.0 domain lens into a domainLensSource state home and project it through a pure domain lens, alongside a domainBar home for the word-cloud presentation settings. Merge domain words per file on load, re-keying paths the way edges are handled so multi-file mode aggregates correctly, and persist the domainBar settings via saveCcState. Also teach the ValidationTool schema and simplecc.sh about the domain lens, and carry the lens in both sample1.cc.json copies. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ValidationTool/src/main/resources/cc.json | 30 ++- .../tools/validation/EveritValidatorTest.kt | 41 +++++ analysis/script/simplecc.sh | 25 ++- dev_docs/cc-json-2.0-format.md | 5 +- dev_docs/cc-json-2.0.schema.json | 23 ++- .../app/codeCharta/assets/sample1.cc.json | 36 ++++ .../lenses/domain/domainLens.facade.ts | 11 ++ .../domain/store/domain.selectors.spec.ts | 174 ++++++++++++++++++ .../lenses/domain/store/domain.selectors.ts | 38 ++++ .../reconcileAfterLoad.effect.spec.ts | 42 ++++- .../reconcileAfterLoad.effect.ts | 5 + .../utils/domainWords.merger.spec.ts | 164 +++++++++++++++++ .../utils/domainWords.merger.ts | 92 +++++++++ .../actionsRequiringSaveCcState.ts | 21 +++ .../saveCcState/saveCcState.effect.spec.ts | 13 ++ .../app/codeCharta/mocks/dataMocks.ts | 72 +++++++- .../app/codeCharta/model/ccjson2.model.ts | 11 +- .../app/codeCharta/model/domain.model.ts | 20 ++ .../app/codeCharta/model/state.model.ts | 4 + .../app/codeCharta/model/wordCloud.model.ts | 57 ++++++ .../stores/domainBar/domainBar.read.facade.ts | 2 + .../domainBar/domainBar.write.facade.ts | 14 ++ .../store/domainBar.readWindow.spec.ts | 38 ++++ .../domainBar/store/domainBar.readWindow.ts | 37 ++++ .../domainBar/store/domainBar.reducer.ts | 34 ++++ .../domainBar/store/domainBar.selector.ts | 3 + .../stores/domainBar/store/domainBar.spec.ts | 90 +++++++++ .../store/gridSize/gridSize.actions.ts | 4 + .../store/gridSize/gridSize.reducer.ts | 7 + .../store/gridSize/gridSize.selector.ts | 4 + .../rotationRange/rotationRange.actions.ts | 7 + .../rotationRange/rotationRange.reducer.ts | 7 + .../rotationRange/rotationRange.selector.ts | 4 + .../rotationStep/rotationStep.actions.ts | 4 + .../rotationStep/rotationStep.reducer.ts | 7 + .../rotationStep/rotationStep.selector.ts | 4 + .../domainBar/store/shape/shape.actions.ts | 4 + .../domainBar/store/shape/shape.reducer.ts | 7 + .../domainBar/store/shape/shape.selector.ts | 4 + .../store/shrinkToFit/shrinkToFit.actions.ts | 4 + .../store/shrinkToFit/shrinkToFit.reducer.ts | 7 + .../store/shrinkToFit/shrinkToFit.selector.ts | 4 + .../store/sizeRange/sizeRange.actions.ts | 4 + .../store/sizeRange/sizeRange.reducer.ts | 7 + .../store/sizeRange/sizeRange.selector.ts | 4 + .../store/sizingMode/sizingMode.actions.ts | 4 + .../store/sizingMode/sizingMode.reducer.ts | 7 + .../store/sizingMode/sizingMode.selector.ts | 4 + .../domainBar/store/topN/topN.actions.ts | 4 + .../domainBar/store/topN/topN.reducer.ts | 7 + .../domainBar/store/topN/topN.selector.ts | 4 + .../store/wordCloudSettings.selector.ts | 18 ++ .../domainLensSource.read.facade.ts | 3 + .../domainLensSource.write.facade.ts | 6 + .../store/domainLensSource.readWindow.spec.ts | 39 ++++ .../store/domainLensSource.readWindow.ts | 24 +++ .../store/domainLensSource.reducer.ts | 14 ++ .../store/domainLensSource.selector.ts | 3 + .../store/words/words.actions.ts | 4 + .../store/words/words.reducer.spec.ts | 31 ++++ .../store/words/words.reducer.ts | 7 + .../store/words/words.selector.ts | 4 + .../stores/fileStore/fileStore.facade.ts | 2 +- .../ccJson/services/loadFile.service.spec.ts | 3 +- .../__snapshots__/ccFileHelper.spec.ts.snap | 1 + .../util/ccJson2/ccJson2ToCCFile.spec.ts | 42 +++++ .../ccJson/util/ccJson2/ccJson2ToCCFile.ts | 20 ++ .../loaders/ccJson/util/fileValidator.spec.ts | 66 +++++++ .../loaders/ccJson/util/fileValidator.ts | 25 ++- .../store/filesLoaded/filesLoaded.actions.ts | 3 +- .../store/visibleFileStates.selector.ts | 17 +- .../indexedDB/indexedDBWriter.spec.ts | 62 +++++++ .../rootStore/indexedDB/indexedDBWriter.ts | 39 +++- .../stores/rootStore/state.manager.ts | 9 + .../app/codeCharta/stores/rootStore/store.ts | 4 + .../aggregationGenerator.spec.ts.snap | 2 + .../codeCharta/util/aggregationGenerator.ts | 3 +- .../app/codeCharta/util/ccJson2Schema.json | 23 ++- .../app/codeCharta/util/deltaGenerator.ts | 3 +- .../public/codeCharta/assets/sample1.cc.json | 36 ++++ 80 files changed, 1708 insertions(+), 30 deletions(-) create mode 100644 visualization/app/codeCharta/lenses/domain/domainLens.facade.ts create mode 100644 visualization/app/codeCharta/lenses/domain/store/domain.selectors.spec.ts create mode 100644 visualization/app/codeCharta/lenses/domain/store/domain.selectors.ts create mode 100644 visualization/app/codeCharta/load/effects/reconcileAfterLoad/utils/domainWords.merger.spec.ts create mode 100644 visualization/app/codeCharta/load/effects/reconcileAfterLoad/utils/domainWords.merger.ts create mode 100644 visualization/app/codeCharta/model/wordCloud.model.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/domainBar.read.facade.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/domainBar.write.facade.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/domainBar.readWindow.spec.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/domainBar.readWindow.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/domainBar.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/domainBar.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/domainBar.spec.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.actions.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.actions.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.actions.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/shape/shape.actions.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/shape/shape.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/shape/shape.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.actions.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.actions.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.actions.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/topN/topN.actions.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/topN/topN.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/topN/topN.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainBar/store/wordCloudSettings.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/domainLensSource.read.facade.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/domainLensSource.write.facade.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.readWindow.spec.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.readWindow.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.selector.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/store/words/words.actions.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/store/words/words.reducer.spec.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/store/words/words.reducer.ts create mode 100644 visualization/app/codeCharta/stores/domainLensSource/store/words/words.selector.ts diff --git a/analysis/analysers/tools/ValidationTool/src/main/resources/cc.json b/analysis/analysers/tools/ValidationTool/src/main/resources/cc.json index d8da63caaa..747ac7d112 100644 --- a/analysis/analysers/tools/ValidationTool/src/main/resources/cc.json +++ b/analysis/analysers/tools/ValidationTool/src/main/resources/cc.json @@ -636,6 +636,34 @@ ], "type": "object" }, + "DomainLens": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/DomainWord" + }, + "type": "array" + }, + "type": "object" + }, + "DomainWord": { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + }, + "frequency": { + "type": "number" + }, + "tfidf": { + "type": "number" + } + }, + "required": [ + "text", + "frequency" + ], + "type": "object" + }, "Lenses": { "properties": { "metrics": { @@ -648,7 +676,7 @@ "$ref": "#/definitions/ClustersLens" }, "domain": { - "type": "object" + "$ref": "#/definitions/DomainLens" }, "security": { "type": "object" diff --git a/analysis/analysers/tools/ValidationTool/src/test/kotlin/de/maibornwolff/codecharta/analysers/tools/validation/EveritValidatorTest.kt b/analysis/analysers/tools/ValidationTool/src/test/kotlin/de/maibornwolff/codecharta/analysers/tools/validation/EveritValidatorTest.kt index 01997581fc..4be7d34893 100644 --- a/analysis/analysers/tools/ValidationTool/src/test/kotlin/de/maibornwolff/codecharta/analysers/tools/validation/EveritValidatorTest.kt +++ b/analysis/analysers/tools/ValidationTool/src/test/kotlin/de/maibornwolff/codecharta/analysers/tools/validation/EveritValidatorTest.kt @@ -306,6 +306,47 @@ class EveritValidatorTest { Assertions.assertThat(thrown.message).contains("ghost-metric").contains("ghost-edge") } + @Test + fun `should accept a 2_0 file whose domain lens maps node ids to word banks`() { + // Arrange: the shape DomainProjectGenerator emits — text + frequency always, tfidf optional. + val validDomainLens = + """{"meta":{"projectName":"p","apiVersion":"2.0","checksum":"x"},""" + + """"files":[{"id":"app-id","name":"root","type":"Folder"}],""" + + """"lenses":{"domain":{"app-id":[{"text":"invoice","frequency":12,"tfidf":0.42},{"text":"customer","frequency":3}]}}}""" + + // Act + Assert + validator.validate(ByteArrayInputStream(validDomainLens.toByteArray())) + } + + @Test + fun `should reject a 2_0 file whose domain word bank is not an array`() { + // Arrange: a hand-edited file carrying a single word object instead of the required word array — + // ccsh check must refuse it rather than pass a file the visualization then fails to load. + val objectWordBank = + """{"meta":{"projectName":"p","apiVersion":"2.0","checksum":"x"},""" + + """"files":[{"id":"app-id","name":"root","type":"Folder"}],""" + + """"lenses":{"domain":{"app-id":{"text":"invoice","frequency":12}}}}""" + + // Act + Assert + assertFailsWith(ValidationException::class) { + validator.validate(ByteArrayInputStream(objectWordBank.toByteArray())) + } + } + + @Test + fun `should reject a 2_0 file whose domain word is missing its frequency`() { + // Arrange: every word carries a raw occurrence count; only tfidf is optional. + val wordWithoutFrequency = + """{"meta":{"projectName":"p","apiVersion":"2.0","checksum":"x"},""" + + """"files":[{"id":"app-id","name":"root","type":"Folder"}],""" + + """"lenses":{"domain":{"app-id":[{"text":"invoice"}]}}}""" + + // Act + Assert + assertFailsWith(ValidationException::class) { + validator.validate(ByteArrayInputStream(wordWithoutFrequency.toByteArray())) + } + } + @Test fun `should reject an unwrapped legacy 1_x file with a convert hint`() { // Act diff --git a/analysis/script/simplecc.sh b/analysis/script/simplecc.sh index 4b0bcb45dd..c1548a2dc5 100755 --- a/analysis/script/simplecc.sh +++ b/analysis/script/simplecc.sh @@ -55,7 +55,8 @@ ANALYSIS STEPS: 3. Tokei - Language statistics (requires: tokei) 4. GitLogParser - Git commit history metrics (requires: git, git repo) 5. RawTextParser - Raw text metrics (requires: ccsh) - 6. SonarImporter - SonarQube metrics (requires: sonar-scanner, token) + 6. DomainLanguage - Domain vocabulary lens (requires: ccsh) + 7. SonarImporter - SonarQube metrics (requires: sonar-scanner, token) Only ccsh is mandatory. All other tools are optional. @@ -240,6 +241,9 @@ main() { # --- RawTextParser (mandatory with ccsh) --- run_rawtext_analysis + # --- DomainLanguageParser (optional, needs a ccsh that ships it) --- + run_domain_language_analysis + # --- SonarImporter (optional) --- run_sonar_import @@ -430,6 +434,25 @@ run_rawtext_analysis() { fi } +run_domain_language_analysis() { + echo "" + echo "Domain Language Analysis" + echo "========================" + + # The domain lens is a newer analyser; older ccsh installations do not ship it. + if ! ccsh domainlanguageparser --help >/dev/null 2>&1; then + skip_step "Domain Language Analysis" "ccsh does not provide domainlanguageparser (update ccsh)" + return + fi + + if ccsh domainlanguageparser . -o "$TEMP_DIR/domain.${FILE_EXTENSION}"; then + GENERATED_FILES+=("$TEMP_DIR/domain.${FILE_EXTENSION}") + echo " Generated domain.${FILE_EXTENSION}" + else + skip_step "Domain Language Analysis" "ccsh domainlanguageparser failed" + fi +} + run_sonar_import() { echo "" echo "SonarQube Import" diff --git a/dev_docs/cc-json-2.0-format.md b/dev_docs/cc-json-2.0-format.md index 604000b219..030733ef8b 100644 --- a/dev_docs/cc-json-2.0-format.md +++ b/dev_docs/cc-json-2.0-format.md @@ -34,7 +34,7 @@ backend and a frontend built separately, or a coverage report rooted by package) "metrics": { "attributes": { "": { "rloc": 120, "mcc": 8 } }, "attributeDescriptors": {}, "attributeTypes": {} }, "dependency": { "edges": [ { "fromId": "", "toId": "", "attributes": { "pairingRate": 42 } } ], "attributeTypes": {}, "attributeDescriptors": {} }, "clusters": { "clusterings": { "author-ownership": { "title": "Author ownership", "membership": "weighted", "weightBasis": "rloc", "analyzers": ["gitlogparser"], "clusters": [ { "id": "author-a", "name": "Author A", "members": [ { "nodeId": "", "weight": 0.62 } ] } ] } } }, - "domain": {}, + "domain": { "": [ { "text": "invoice", "frequency": 12, "tfidf": 0.42 } ] }, "security": {} } } @@ -45,7 +45,8 @@ backend and a frontend built separately, or a coverage report rooted by package) - **`lenses`** are additive overlays joined to `files` by `id`. `metrics` and `dependency` are concrete; `clusters` is optional and fully defined by the schema but has no producer or visualization support yet — see [the `clusters` lens](cc-json-2.0-clusters-lens.md) for its full - definition and merge semantics; `domain` and `security` are reserved. **Unknown top-level lenses + definition and merge semantics; `domain` maps a node id to its word bank (each word carrying `text`, + `frequency` and an optional `tfidf`); `security` is reserved. **Unknown top-level lenses are preserved verbatim** on round-trip, so a newer tool's lens survives an older tool. - **`meta.checksum`** is an MD5 over the serialized `files` + `lenses` payload (folded into `meta`, unlike the 1.5 `{ checksum, data }` wrapper). `commitHash` is an optional short git SHA. diff --git a/dev_docs/cc-json-2.0.schema.json b/dev_docs/cc-json-2.0.schema.json index 3fc5e01c4e..7992ff359f 100644 --- a/dev_docs/cc-json-2.0.schema.json +++ b/dev_docs/cc-json-2.0.schema.json @@ -58,13 +58,13 @@ "type": "string" }, "Lenses": { - "description": "Additive overlays joined to `files` by id. `metrics`, `dependency` and `clusters` are concrete; `domain`/`security` are reserved; any unknown top-level lens is preserved verbatim on round-trip (hence no additionalProperties:false here).", + "description": "Additive overlays joined to `files` by id. `metrics`, `dependency`, `clusters` and `domain` are concrete; `security` is reserved; any unknown top-level lens is preserved verbatim on round-trip (hence no additionalProperties:false here).", "type": "object", "properties": { "metrics": { "$ref": "#/definitions/MetricsLens" }, "dependency": { "$ref": "#/definitions/DependencyLens" }, "clusters": { "$ref": "#/definitions/ClustersLens" }, - "domain": { "type": "object" }, + "domain": { "$ref": "#/definitions/DomainLens" }, "security": { "type": "object" } } }, @@ -193,6 +193,25 @@ "weight": { "type": "number", "minimum": 0, "maximum": 1 } } }, + "DomainLens": { + "description": "Per-node domain vocabulary, keyed by node id: the word bank the domain bar and word cloud render.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "$ref": "#/definitions/DomainWord" } + } + }, + "DomainWord": { + "description": "One extracted domain term for a node. `frequency` is the raw occurrence count; `tfidf` is the optional corpus-relative weight.", + "type": "object", + "additionalProperties": false, + "required": ["text", "frequency"], + "properties": { + "text": { "type": "string" }, + "frequency": { "type": "number" }, + "tfidf": { "type": "number" } + } + }, "AttributeDescriptor": { "description": "Human-facing metadata for a metric/edge attribute.", "type": "object", diff --git a/visualization/app/codeCharta/assets/sample1.cc.json b/visualization/app/codeCharta/assets/sample1.cc.json index 37f5bcb262..e6cc9baa3e 100755 --- a/visualization/app/codeCharta/assets/sample1.cc.json +++ b/visualization/app/codeCharta/assets/sample1.cc.json @@ -114,6 +114,42 @@ "avgCommits": "absolute" }, "attributeDescriptors": {} + }, + "domain": { + "164ddff4bb1345e1": [ + { "text": "invoice", "frequency": 42, "tfidf": 0.81 }, + { "text": "payment", "frequency": 30, "tfidf": 0.64 }, + { "text": "customer", "frequency": 25, "tfidf": 0.55 }, + { "text": "account", "frequency": 20, "tfidf": 0.52 }, + { "text": "order", "frequency": 18, "tfidf": 0.41 }, + { "text": "ledger", "frequency": 16, "tfidf": 0.44 }, + { "text": "balance", "frequency": 14, "tfidf": 0.37 }, + { "text": "shipment", "frequency": 12, "tfidf": 0.28 }, + { "text": "transaction", "frequency": 9, "tfidf": 0.22 } + ], + "1ae98c1a93690d75": [ + { "text": "account", "frequency": 20, "tfidf": 0.52 }, + { "text": "balance", "frequency": 14, "tfidf": 0.37 } + ], + "7de6343f370ff7cf": [ + { "text": "ledger", "frequency": 16, "tfidf": 0.44 }, + { "text": "transaction", "frequency": 9, "tfidf": 0.22 } + ], + "3d6521b0884ce9f4": [ + { "text": "invoice", "frequency": 42, "tfidf": 0.81 }, + { "text": "payment", "frequency": 30, "tfidf": 0.64 }, + { "text": "customer", "frequency": 25, "tfidf": 0.55 }, + { "text": "order", "frequency": 18, "tfidf": 0.41 }, + { "text": "shipment", "frequency": 12, "tfidf": 0.28 } + ], + "ea70504d5daa3547": [ + { "text": "account", "frequency": 12, "tfidf": 0.52 }, + { "text": "balance", "frequency": 5, "tfidf": 0.37 } + ], + "120a1569e3556450": [ + { "text": "balance", "frequency": 9, "tfidf": 0.37 }, + { "text": "account", "frequency": 8, "tfidf": 0.52 } + ] } } } diff --git a/visualization/app/codeCharta/lenses/domain/domainLens.facade.ts b/visualization/app/codeCharta/lenses/domain/domainLens.facade.ts new file mode 100644 index 0000000000..7c7cf213c2 --- /dev/null +++ b/visualization/app/codeCharta/lenses/domain/domainLens.facade.ts @@ -0,0 +1,11 @@ +/** + * Public surface of the domain lens — a pure read projection of the cc.json `domain` lens word bank. + * Consumers read it through these selectors; selection reaches the lens only as an explicit parameter + * (see `wordsForSelectedNodeSelector`), never by the lens reading view state. + */ +export { + hasDomainDataSelector, + hasTfidfDataSelector, + isLoadedFileSetWithoutDomainLensSelector, + wordsForSelectedNodeSelector +} from "./store/domain.selectors" diff --git a/visualization/app/codeCharta/lenses/domain/store/domain.selectors.spec.ts b/visualization/app/codeCharta/lenses/domain/store/domain.selectors.spec.ts new file mode 100644 index 0000000000..8288ade82f --- /dev/null +++ b/visualization/app/codeCharta/lenses/domain/store/domain.selectors.spec.ts @@ -0,0 +1,174 @@ +import { STATE } from "../../../mocks/dataMocks" +import { CcState, DomainLensData, DomainWord, FileSelectionState, FileState } from "../../../model/codeCharta.model" +import { fileRoot } from "../../../util/fileRoot" +import { + hasDomainDataSelector, + hasTfidfDataSelector, + isLoadedFileSetWithoutDomainLensSelector, + wordsForSelectedNodeSelector +} from "./domain.selectors" + +describe("domain lens selectors", () => { + const rootWords: DomainWord[] = [{ text: "invoice", frequency: 12, tfidf: 0.4 }] + const leafWords: DomainWord[] = [{ text: "payment", frequency: 5 }] + const persistedFileChecksum = "checksum-of-the-one-loaded-file" + + function stateWithWords(words: Record): CcState { + return { ...STATE, domainLensSource: { words } } + } + + function aFileState(domainWords: DomainLensData, selectedAs: FileSelectionState): FileState { + return { + file: { fileMeta: { fileChecksum: persistedFileChecksum }, settings: { fileSettings: { domainWords } } }, + selectedAs + } as FileState + } + + function stateWithFiles(files: FileState[]): CcState { + return { ...STATE, files } + } + + describe("hasDomainDataSelector", () => { + it("should be false when no domain words are present", () => { + // Arrange + const state = stateWithWords({}) + + // Act + const result = hasDomainDataSelector(state) + + // Assert + expect(result).toBe(false) + }) + + it("should be true when at least one path carries domain words", () => { + // Arrange + const state = stateWithWords({ "/root": rootWords }) + + // Act + const result = hasDomainDataSelector(state) + + // Assert + expect(result).toBe(true) + }) + }) + + describe("hasTfidfDataSelector", () => { + it("should be false when no word carries a tfidf score", () => { + // Arrange + const state = stateWithWords({ "/root": leafWords }) + + // Act + const result = hasTfidfDataSelector(state) + + // Assert + expect(result).toBe(false) + }) + + it("should be true when at least one word carries a tfidf score", () => { + // Arrange + const state = stateWithWords({ "/root": rootWords }) + + // Act + const result = hasTfidfDataSelector(state) + + // Assert + expect(result).toBe(true) + }) + }) + + describe("wordsForSelectedNodeSelector", () => { + it("should return the selected node's words when an id is given", () => { + // Arrange + const state = stateWithWords({ "/root/leaf": leafWords }) + + // Act + const result = wordsForSelectedNodeSelector("/root/leaf")(state) + + // Assert + expect(result).toEqual(leafWords) + }) + + it("should fall back to the root path when the selected id is null", () => { + // Arrange + fileRoot.updateRoot("root") + const state = stateWithWords({ [fileRoot.rootPath]: rootWords }) + + // Act + const result = wordsForSelectedNodeSelector(null)(state) + + // Assert + expect(result).toEqual(rootWords) + }) + }) + + describe("isLoadedFileSetWithoutDomainLensSelector", () => { + it("should be false before any file is loaded, so a domain deep link survives its own boot", () => { + // Arrange + const state = stateWithFiles([]) + + // Act + const result = isLoadedFileSetWithoutDomainLensSelector(state) + + // Assert + expect(result).toBe(false) + }) + + it("should be true when every visible file carries an empty domain bank", () => { + // Arrange + const state = stateWithFiles([aFileState({}, FileSelectionState.Partial)]) + + // Act + const result = isLoadedFileSetWithoutDomainLensSelector(state) + + // Assert + expect(result).toBe(true) + }) + + it("should be false when at least one visible file carries a domain bank", () => { + // Arrange + const state = stateWithFiles([ + aFileState({}, FileSelectionState.Partial), + aFileState({ "/root": rootWords }, FileSelectionState.Partial) + ]) + + // Act + const result = isLoadedFileSetWithoutDomainLensSelector(state) + + // Assert + expect(result).toBe(false) + }) + + it("should ignore deselected files, which the domain view never renders", () => { + // Arrange + const state = stateWithFiles([ + aFileState({}, FileSelectionState.Partial), + aFileState({ "/root": rootWords }, FileSelectionState.None) + ]) + + // Act + const result = isLoadedFileSetWithoutDomainLensSelector(state) + + // Assert + expect(result).toBe(true) + }) + + /** + * The IndexedDB restore commits the same file twice under one checksum: first re-parsed from the + * persisted state, which loses the domain lens on the way through the flat 1.x export shape, and + * only then the persisted file state that still carries it. A projection memoized on visible-file + * checksums cannot tell those two apart, which is why this selector must not be built on one. + */ + it("should re-project when a restore replaces the parsed file with the persisted one", () => { + // Arrange — the lossy re-parse is read first, exactly as the restore commits it + const parsedState = stateWithFiles([aFileState({}, FileSelectionState.Partial)]) + expect(isLoadedFileSetWithoutDomainLensSelector(parsedState)).toBe(true) + + // Act — the persisted file state follows: same checksum, domain lens intact + const restoredState = stateWithFiles([aFileState({ "/root": rootWords }, FileSelectionState.Partial)]) + const result = isLoadedFileSetWithoutDomainLensSelector(restoredState) + + // Assert + expect(result).toBe(false) + }) + }) +}) diff --git a/visualization/app/codeCharta/lenses/domain/store/domain.selectors.ts b/visualization/app/codeCharta/lenses/domain/store/domain.selectors.ts new file mode 100644 index 0000000000..bbbf65940e --- /dev/null +++ b/visualization/app/codeCharta/lenses/domain/store/domain.selectors.ts @@ -0,0 +1,38 @@ +import { createSelector } from "@ngrx/store" +import { FileState } from "../../../model/codeCharta.model" +import { domainWordsSelector } from "../../../stores/domainLensSource/domainLensSource.read.facade" +import { visibleFileStatesWithCurrentSettingsSelector } from "../../../stores/fileStore/fileStore.facade" +import { fileRoot } from "../../../util/fileRoot" + +/** Whether the loaded file exposes any domain words at all — gates the domain view / view switcher. */ +export const hasDomainDataSelector = createSelector(domainWordsSelector, words => Object.keys(words).length > 0) + +/** Whether any domain word carries a tfidf score — gates the tfidf sizing option in the domain bar. */ +export const hasTfidfDataSelector = createSelector(domainWordsSelector, words => + Object.values(words).some(wordList => wordList.some(word => word.tfidf !== undefined)) +) + +/** + * The domain words for the selected node. `selectedBuildingId` is a node path (or `null` in the default + * state, before any selection) — passed in by the composing layer so the lens never reads view state + * itself. A `null` id falls back to the root path, whose folder-aggregated words seed the initial cloud. + */ +export const wordsForSelectedNodeSelector = (selectedBuildingId: string | null) => + createSelector(domainWordsSelector, words => words[selectedBuildingId ?? fileRoot.rootPath] ?? []) + +const carriesNoDomainLens = ({ file }: FileState) => Object.keys(file.settings.fileSettings.domainWords).length === 0 + +/** + * True once a file set is loaded in which no file carries a domain lens (a cc.json 1.x file set). + * + * Derived from the per-file banks rather than from `hasDomainDataSelector`: the merged word bank the latter + * reads is only written by `ReconcileAfterLoadEffect` a macrotask after the files reach the store (its + * trigger is buffered behind a `debounceTime(0)`). During that window the merged bank is still the empty + * default, so combining the two facts — however carefully — reports "a file is loaded and it has no domain + * lens" for EVERY freshly loaded file, cc.json 2.0 files included. The per-file banks are part of the very + * state transition that makes the files visible, so reading them has no such window. + */ +export const isLoadedFileSetWithoutDomainLensSelector = createSelector( + visibleFileStatesWithCurrentSettingsSelector, + visibleFileStates => visibleFileStates.length > 0 && visibleFileStates.every(carriesNoDomainLens) +) diff --git a/visualization/app/codeCharta/load/effects/reconcileAfterLoad/reconcileAfterLoad.effect.spec.ts b/visualization/app/codeCharta/load/effects/reconcileAfterLoad/reconcileAfterLoad.effect.spec.ts index 1a4ed120c6..f3624d168f 100644 --- a/visualization/app/codeCharta/load/effects/reconcileAfterLoad/reconcileAfterLoad.effect.spec.ts +++ b/visualization/app/codeCharta/load/effects/reconcileAfterLoad/reconcileAfterLoad.effect.spec.ts @@ -1,9 +1,10 @@ import { TestBed } from "@angular/core/testing" import { EffectsModule } from "@ngrx/effects" import { Action, State, Store, StoreModule } from "@ngrx/store" -import { TEST_FILE_CONTENT } from "../../../mocks/dataMocks" +import { TEST_FILE_CONTENT, TEST_FILE_CONTENT_CC_JSON_2_DOMAIN_A, TEST_FILE_CONTENT_CC_JSON_2_DOMAIN_B } from "../../../mocks/dataMocks" import { CcState, SharedView } from "../../../model/codeCharta.model" import { defaultDependencyLensSource } from "../../../stores/dependencyLensSource/dependencyLensSource.read.facade" +import { defaultDomainLensSource } from "../../../stores/domainLensSource/domainLensSource.read.facade" import { LoadFileService, RestoredSettings, setDeltaReference, setStandard } from "../../../stores/fileStore/fileStore.facade" import { filesLoaded } from "../../../stores/fileStore/store/filesLoaded/filesLoaded.actions" import { defaultMetricsLensSource } from "../../../stores/metricsLensSource/metricsLensSource.read.facade" @@ -22,6 +23,9 @@ import { ReconcileAfterLoadEffect } from "./reconcileAfterLoad.effect" * names the effect whose behavior it inherited. */ describe("ReconcileAfterLoadEffect", () => { + const DOMAIN_FILE_NAME_A = "domainA.cc.json" + const DOMAIN_FILE_NAME_B = "domainB.cc.json" + let store: Store let state: State let loadFileService: LoadFileService @@ -49,7 +53,8 @@ describe("ReconcileAfterLoadEffect", () => { const aRestoredSettings = (sharedView: Partial): RestoredSettings => ({ sharedView: { ...defaultSharedView, ...sharedView }, metricsLensSource: defaultMetricsLensSource, - dependencyLensSource: defaultDependencyLensSource + dependencyLensSource: defaultDependencyLensSource, + domainLensSource: defaultDomainLensSource }) /** The sequence is debounced onto the next macrotask; this is how a test waits for it. */ @@ -62,6 +67,16 @@ describe("ReconcileAfterLoadEffect", () => { await flushDebounce() } + /** Loads the two domain-lens maps together, so the merger runs in multiple mode over both banks. */ + const loadDomainLensFilesAndSignal = async () => { + loadFileService.loadFiles([ + { fileName: DOMAIN_FILE_NAME_A, fileSize: 42, content: clone(TEST_FILE_CONTENT_CC_JSON_2_DOMAIN_A) }, + { fileName: DOMAIN_FILE_NAME_B, fileSize: 42, content: clone(TEST_FILE_CONTENT_CC_JSON_2_DOMAIN_B) } + ]) + store.dispatch(aFilesLoaded()) + await flushDebounce() + } + const dispatchedActionsOfType = (type: string): Action[] => dispatchSpy.mock.calls.map(call => call[0] as Action).filter(action => action.type === type) @@ -110,12 +125,33 @@ describe("ReconcileAfterLoadEffect", () => { value: expect.objectContaining({ sharedView: expect.objectContaining({ blacklist: expect.any(Array), markedPackages: expect.any(Array) }), metricsLensSource: expect.objectContaining({ attributeTypes: expect.anything() }), - dependencyLensSource: expect.objectContaining({ attributeTypes: expect.anything() }) + dependencyLensSource: expect.objectContaining({ attributeTypes: expect.anything() }), + domainLensSource: expect.objectContaining({ words: expect.anything() }) }) }) ) }) + it("should merge the domain words of all loaded files, re-keyed onto the aggregated map's paths", async () => { + // Act + await loadDomainLensFilesAndSignal() + + // Assert — each file's bank under its own subtree, plus the summed bank on the aggregated root + expect(state.getValue().domainLensSource.words).toEqual({ + "/root": [ + { text: "payment", frequency: 10, tfidf: 0.4 }, + { text: "shipping", frequency: 3 } + ], + [`/root/${DOMAIN_FILE_NAME_A}`]: [{ text: "payment", frequency: 4, tfidf: 0.4 }], + [`/root/${DOMAIN_FILE_NAME_A}/big.ts`]: [{ text: "invoice", frequency: 2 }], + [`/root/${DOMAIN_FILE_NAME_B}`]: [ + { text: "payment", frequency: 6, tfidf: 0.2 }, + { text: "shipping", frequency: 3 } + ], + [`/root/${DOMAIN_FILE_NAME_B}/small.ts`]: [{ text: "cart", frequency: 5 }] + }) + }) + // ── replaces LoadFileService.referenceFileSubscription ───────────────────────────── it("should update the file root when the reference file changes", async () => { diff --git a/visualization/app/codeCharta/load/effects/reconcileAfterLoad/reconcileAfterLoad.effect.ts b/visualization/app/codeCharta/load/effects/reconcileAfterLoad/reconcileAfterLoad.effect.ts index ee4f460c08..f11cf87d8c 100644 --- a/visualization/app/codeCharta/load/effects/reconcileAfterLoad/reconcileAfterLoad.effect.ts +++ b/visualization/app/codeCharta/load/effects/reconcileAfterLoad/reconcileAfterLoad.effect.ts @@ -41,6 +41,7 @@ import { MetricSelection, resolveMetricSelection } from "./resolveMetricSelectio import { getMergedAttributeDescriptors } from "./utils/attributeDescriptors.merger" import { getMergedAttributeTypes } from "./utils/attributeTypes.merger" import { getMergedBlacklist } from "./utils/blacklist.merger" +import { getMergedDomainWords } from "./utils/domainWords.merger" import { getMergedMarkedPackages } from "./utils/markedPackages.merger" /** @@ -208,6 +209,7 @@ export class ReconcileAfterLoadEffect { this.loadInitialFileStore.applyMetricsLensSource(restoredSettings.metricsLensSource) this.loadInitialFileStore.applyDependencyLensSource(restoredSettings.dependencyLensSource) + this.loadInitialFileStore.applyDomainLensSource(restoredSettings.domainLensSource) this.loadInitialFileStore.applySharedView(restoredSettings.sharedView) } @@ -235,6 +237,9 @@ export class ReconcileAfterLoadEffect { }, dependencyLensSource: { attributeTypes: mergedAttributeTypes.edges + }, + domainLensSource: { + words: getMergedDomainWords(visibleFiles, withUpdatedPath) } } }) diff --git a/visualization/app/codeCharta/load/effects/reconcileAfterLoad/utils/domainWords.merger.spec.ts b/visualization/app/codeCharta/load/effects/reconcileAfterLoad/utils/domainWords.merger.spec.ts new file mode 100644 index 0000000000..e6f590133e --- /dev/null +++ b/visualization/app/codeCharta/load/effects/reconcileAfterLoad/utils/domainWords.merger.spec.ts @@ -0,0 +1,164 @@ +import { TEST_FILE_DATA } from "../../../../mocks/dataMocks" +import { CCFile } from "../../../../model/codeCharta.model" +import { clone } from "../../../../util/clone" +import { getMergedDomainWords } from "./domainWords.merger" + +describe("DomainWordsMerger", () => { + let file1: CCFile + let file2: CCFile + + beforeEach(() => { + file1 = clone(TEST_FILE_DATA) + file1.fileMeta.fileName = "file1" + + file2 = clone(TEST_FILE_DATA) + file2.fileMeta.fileName = "file2" + }) + + describe("getMergedDomainWords", () => { + it("should return the words of a single file unchanged", () => { + // Arrange + file1.settings.fileSettings.domainWords = { "/root/nodeA": [{ text: "invoice", frequency: 3 }] } + + // Act + const result = getMergedDomainWords([file1], false) + + // Assert + expect(result).toEqual({ "/root/nodeA": [{ text: "invoice", frequency: 3 }] }) + }) + + it("should merge the path-keyed word banks of all files", () => { + // Arrange + file1.settings.fileSettings.domainWords = { "/root/nodeA": [{ text: "invoice", frequency: 3 }] } + file2.settings.fileSettings.domainWords = { "/root/nodeB": [{ text: "payment", frequency: 5 }] } + + // Act + const result = getMergedDomainWords([file1, file2], false) + + // Assert + expect(result).toEqual({ + "/root/nodeA": [{ text: "invoice", frequency: 3 }], + "/root/nodeB": [{ text: "payment", frequency: 5 }] + }) + }) + + it("should re-key the paths of each file when the map is aggregated", () => { + // Arrange + file1.settings.fileSettings.domainWords = { "/root": [{ text: "first", frequency: 1 }] } + file2.settings.fileSettings.domainWords = { "/root/nodeB": [{ text: "payment", frequency: 5 }] } + + // Act + const result = getMergedDomainWords([file1, file2], true) + + // Assert + expect(result).toEqual({ + "/root": [{ text: "first", frequency: 1 }], + "/root/file1": [{ text: "first", frequency: 1 }], + "/root/file2/nodeB": [{ text: "payment", frequency: 5 }] + }) + }) + + it("should aggregate the root word banks of all files onto the aggregated root", () => { + // Arrange + file1.settings.fileSettings.domainWords = { + "/root": [ + { text: "invoice", frequency: 1 }, + { text: "shared", frequency: 2 } + ] + } + file2.settings.fileSettings.domainWords = { + "/root": [ + { text: "shared", frequency: 3 }, + { text: "payment", frequency: 4 } + ] + } + + // Act + const result = getMergedDomainWords([file1, file2], true) + + // Assert + expect(result["/root"]).toEqual([ + { text: "invoice", frequency: 1 }, + { text: "shared", frequency: 5 }, + { text: "payment", frequency: 4 } + ]) + }) + + it("should keep the re-keyed bank of each file next to the aggregated root", () => { + // Arrange + file1.settings.fileSettings.domainWords = { "/root": [{ text: "invoice", frequency: 1 }] } + file2.settings.fileSettings.domainWords = { "/root": [{ text: "payment", frequency: 4 }] } + + // Act + const result = getMergedDomainWords([file1, file2], true) + + // Assert + expect(result["/root/file1"]).toEqual([{ text: "invoice", frequency: 1 }]) + expect(result["/root/file2"]).toEqual([{ text: "payment", frequency: 4 }]) + }) + + it("should keep the strongest tfidf when aggregating a word onto the aggregated root", () => { + // Arrange + file1.settings.fileSettings.domainWords = { "/root": [{ text: "shared", frequency: 2, tfidf: 0.8 }] } + file2.settings.fileSettings.domainWords = { "/root": [{ text: "shared", frequency: 3, tfidf: 0.1 }] } + + // Act + const result = getMergedDomainWords([file1, file2], true) + + // Assert + expect(result["/root"]).toEqual([{ text: "shared", frequency: 5, tfidf: 0.8 }]) + }) + + it("should let the later file win per word when both files share a path in delta mode", () => { + // Arrange + file1.settings.fileSettings.domainWords = { + "/root": [ + { text: "invoice", frequency: 1 }, + { text: "shared", frequency: 2, tfidf: 0.8 } + ] + } + file2.settings.fileSettings.domainWords = { + "/root": [ + { text: "shared", frequency: 3, tfidf: 0.1 }, + { text: "payment", frequency: 4 } + ] + } + + // Act + const result = getMergedDomainWords([file1, file2], false) + + // Assert + expect(result).toEqual({ + "/root": [ + { text: "invoice", frequency: 1 }, + { text: "shared", frequency: 3, tfidf: 0.1 }, + { text: "payment", frequency: 4 } + ] + }) + }) + + it("should not mutate the word bank of the merged files", () => { + // Arrange + file1.settings.fileSettings.domainWords = { "/root": [{ text: "shared", frequency: 2 }] } + file2.settings.fileSettings.domainWords = { "/root": [{ text: "shared", frequency: 3 }] } + + // Act + getMergedDomainWords([file1, file2], false) + + // Assert + expect(file1.settings.fileSettings.domainWords).toEqual({ "/root": [{ text: "shared", frequency: 2 }] }) + }) + + it("should return an empty map when no file has domain words", () => { + // Arrange + file1.settings.fileSettings.domainWords = {} + file2.settings.fileSettings.domainWords = {} + + // Act + const result = getMergedDomainWords([file1, file2], false) + + // Assert + expect(result).toEqual({}) + }) + }) +}) diff --git a/visualization/app/codeCharta/load/effects/reconcileAfterLoad/utils/domainWords.merger.ts b/visualization/app/codeCharta/load/effects/reconcileAfterLoad/utils/domainWords.merger.ts new file mode 100644 index 0000000000..2075a19f7a --- /dev/null +++ b/visualization/app/codeCharta/load/effects/reconcileAfterLoad/utils/domainWords.merger.ts @@ -0,0 +1,92 @@ +import { CCFile, DomainLensData, DomainWord } from "../../../../model/codeCharta.model" +import { fileRoot } from "../../../../util/fileRoot" +import { getUpdatedPath } from "../../../../util/nodePathHelper" + +/** How two banks resolve a word they both carry. */ +type CombineWords = (mergedWord: DomainWord, word: DomainWord) => DomainWord + +/** + * Combines the path-keyed domain word banks of all visible files into one. + * + * In multiple mode the aggregated tree re-roots every file under `/root/`, so each file's + * paths are re-keyed the same way the dependency edges are (see `util/edges/edges.merger.ts`) — + * otherwise no merged path would match a node of the aggregated map. That tree also gains a synthetic + * root above the per-file subtrees which carries no bank of its own, so the per-file root banks are + * additionally aggregated onto it, the way `AggregationGenerator` aggregates the root attributes. + * Without that the default cloud — the one the root path seeds while nothing is selected — would be + * empty for every multi-file map. + * + * In delta mode the paths are left alone, so reference and comparison collide on every shared path. + * There the later file wins per word, the way the edges merger overwrites the attributes of an equal + * edge: summing two revisions of the same map would invent counts that belong to neither of them. + * Words that only one file carries are kept either way, so no file's bank is silently dropped. + */ +export function getMergedDomainWords(inputFiles: CCFile[], withUpdatedPath: boolean): DomainLensData { + if (inputFiles.length === 1) { + return inputFiles[0].settings.fileSettings.domainWords + } + + const mergedWordsByPath = new Map>() + for (const inputFile of inputFiles) { + for (const [path, words] of Object.entries(inputFile.settings.fileSettings.domainWords)) { + if (!withUpdatedPath) { + addWords(mergedWordsByPath, path, words, keepLaterWord) + continue + } + + addWords(mergedWordsByPath, getUpdatedPath(inputFile.fileMeta.fileName, path), words, keepLaterWord) + if (path === fileRoot.rootPath) { + addWords(mergedWordsByPath, fileRoot.rootPath, words, aggregateWords) + } + } + } + + const mergedDomainWords: DomainLensData = {} + for (const [path, wordsByText] of mergedWordsByPath) { + mergedDomainWords[path] = [...wordsByText.values()] + } + return mergedDomainWords +} + +function addWords( + mergedWordsByPath: Map>, + path: string, + words: DomainWord[], + combineWords: CombineWords +): void { + let wordsByText = mergedWordsByPath.get(path) + if (!wordsByText) { + wordsByText = new Map() + mergedWordsByPath.set(path, wordsByText) + } + + for (const word of words) { + const mergedWord = wordsByText.get(word.text) + wordsByText.set(word.text, mergedWord ? combineWords(mergedWord, word) : { ...word }) + } +} + +/** The whole record of the later file wins, so its `frequency` and `tfidf` keep describing one file. */ +function keepLaterWord(_mergedWord: DomainWord, word: DomainWord): DomainWord { + return { ...word } +} + +/** + * The synthetic aggregated root spans files that describe disjoint code bases: their `frequency` + * counts are additive. `tfidf` is a per-file normalized score that cannot be summed or recomputed + * here, so the strongest signal wins — which, unlike picking a file, does not depend on the file order. + */ +function aggregateWords(mergedWord: DomainWord, word: DomainWord): DomainWord { + return { + ...mergedWord, + frequency: mergedWord.frequency + word.frequency, + tfidf: maxTfidf(mergedWord.tfidf, word.tfidf) + } +} + +function maxTfidf(mergedTfidf: number | undefined, tfidf: number | undefined): number | undefined { + if (mergedTfidf === undefined) { + return tfidf + } + return tfidf === undefined ? mergedTfidf : Math.max(mergedTfidf, tfidf) +} diff --git a/visualization/app/codeCharta/load/effects/saveCcState/actionsRequiringSaveCcState.ts b/visualization/app/codeCharta/load/effects/saveCcState/actionsRequiringSaveCcState.ts index 068c15bdf2..5fb7224941 100644 --- a/visualization/app/codeCharta/load/effects/saveCcState/actionsRequiringSaveCcState.ts +++ b/visualization/app/codeCharta/load/effects/saveCcState/actionsRequiringSaveCcState.ts @@ -1,4 +1,13 @@ import { setEdgeAttributeTypes } from "../../../stores/dependencyLensSource/dependencyLensSource.write.facade" +import { + setDomainBarGridSize, + setDomainBarRotationRange, + setDomainBarRotationStep, + setDomainBarShape, + setDomainBarSizeRange, + setDomainBarSizingMode, + setDomainBarTopN +} from "../../../stores/domainBar/domainBar.write.facade" import { fileActions } from "../../../stores/fileStore/fileStore.facade" import { invertColorRange, @@ -110,9 +119,21 @@ const metricsLensSaveActions = [setAttributeTypes, setAttributeDescriptors] const dependencyLensSaveActions = [setEdgeAttributeTypes] +// setDomainWords is deliberately excluded: the words are re-derived from the loaded files, not persisted. +const domainBarSaveActions = [ + setDomainBarShape, + setDomainBarSizeRange, + setDomainBarRotationRange, + setDomainBarRotationStep, + setDomainBarGridSize, + setDomainBarSizingMode, + setDomainBarTopN +] + export const actionsRequiringSaveCcState = [ [...metricsLensSaveActions], [...dependencyLensSaveActions], + [...domainBarSaveActions], [...mapStateSaveActions], [...sharedViewSaveActions], [...preferencesActions], diff --git a/visualization/app/codeCharta/load/effects/saveCcState/saveCcState.effect.spec.ts b/visualization/app/codeCharta/load/effects/saveCcState/saveCcState.effect.spec.ts index e3074df0e1..1cb2e6c27d 100644 --- a/visualization/app/codeCharta/load/effects/saveCcState/saveCcState.effect.spec.ts +++ b/visualization/app/codeCharta/load/effects/saveCcState/saveCcState.effect.spec.ts @@ -5,6 +5,7 @@ import { Action, State } from "@ngrx/store" import { MockStore, provideMockStore } from "@ngrx/store/testing" import { waitFor } from "@testing-library/angular" import { Subject } from "rxjs" +import { setDomainBarTopN } from "../../../stores/domainBar/domainBar.write.facade" import { setFiles } from "../../../stores/fileStore/store/files.actions" import { setShowIncomingEdges } from "../../../stores/mapState/mapState.write.facade" import { writeCcState } from "../../../stores/rootStore/indexedDB/indexedDBWriter" @@ -59,6 +60,18 @@ describe("SaveCcStateEffect", () => { await waitFor(() => expect(writeCcState).toHaveBeenCalledWith(state)) }) + it("should save cc-state on domain-bar settings actions", async () => { + // Arrange + const store = TestBed.inject(MockStore) + + // Act + actions$.next(setDomainBarTopN({ value: 42 })) + store.refreshState() + + // Assert + await waitFor(() => expect(writeCcState).toHaveBeenCalledWith(state)) + }) + it("should debounce save cc-state on multiple actions requiring saving cc-state", async () => { const store = TestBed.inject(MockStore) actions$.next(setFiles({ value: [] })) diff --git a/visualization/app/codeCharta/mocks/dataMocks.ts b/visualization/app/codeCharta/mocks/dataMocks.ts index e33a5faf20..8bf1c821db 100644 --- a/visualization/app/codeCharta/mocks/dataMocks.ts +++ b/visualization/app/codeCharta/mocks/dataMocks.ts @@ -25,6 +25,7 @@ import { SortingOption } from "../model/codeCharta.model" import { FileSelectionState, FileState } from "../model/files/files" +import { defaultWordCloudSettings } from "../model/wordCloud.model" import { isLeaf } from "../util/codeMapHelper" import { UNARY_METRIC } from "../util/metric/unaryMetric" @@ -80,7 +81,8 @@ export const DEFAULT_SETTINGS = { attributeDescriptors: {}, blacklist: [], edges: VALID_EDGES, - markedPackages: [] + markedPackages: [], + domainWords: {} } } export const DEFAULT_CC_FILE_MOCK: CCFile = { @@ -1156,7 +1158,8 @@ export const FIXED_FOLDERS_NESTED_MIXED_WITH_DYNAMIC_ONES_MAP_FILE: CCFile = { attributeDescriptors: {}, blacklist: [], edges: [], - markedPackages: [] + markedPackages: [], + domainWords: {} } } } @@ -1259,7 +1262,8 @@ export const FIXED_FOLDERS_NESTED_MIXED_WITH_A_FILE_MAP_FILE: CCFile = { attributeDescriptors: {}, blacklist: [], edges: [], - markedPackages: [] + markedPackages: [], + domainWords: {} } } } @@ -2065,6 +2069,10 @@ export const STATE: CcState = { dependencyLensSource: { attributeTypes: {} }, + domainLensSource: { + words: {} + }, + domainBar: defaultWordCloudSettings, sharedView: { focusedNodePath: ["/root/ParentLeaf"], searchPattern: "", @@ -2218,6 +2226,10 @@ export const DEFAULT_STATE: CcState = { dependencyLensSource: { attributeTypes: {} }, + domainLensSource: { + words: {} + }, + domainBar: defaultWordCloudSettings, files: [], isLoadingFile: true, currentFilesAreSampleFiles: false @@ -2525,3 +2537,57 @@ export const TEST_FILE_CONTENT_CC_JSON_2: CcJson2 = { } } } + +/** + * A pair of cc.json 2.0 maps that both carry a `domain` lens, so a load of the two exercises the + * per-file re-keying and the aggregated-root fallback of the domain words merger end to end. They + * share the word "payment" on their roots and carry one word of their own each. + */ +export const TEST_FILE_CONTENT_CC_JSON_2_DOMAIN_A: CcJson2 = { + meta: { projectName: "Domain Map A", apiVersion: "2.0", checksum: "valid-md5-domain-a" }, + files: [ + { + id: "/root", + name: "root", + type: NodeType.FOLDER, + children: [{ id: "/root/big.ts", name: "big.ts", type: NodeType.FILE }] + } + ], + lenses: { + metrics: { + attributes: { "/root/big.ts": { rloc: 100 } }, + attributeDescriptors: {}, + attributeTypes: { rloc: AttributeTypeValue.absolute } + }, + domain: { + "/root": [{ text: "payment", frequency: 4, tfidf: 0.4 }], + "/root/big.ts": [{ text: "invoice", frequency: 2 }] + } + } +} + +export const TEST_FILE_CONTENT_CC_JSON_2_DOMAIN_B: CcJson2 = { + meta: { projectName: "Domain Map B", apiVersion: "2.0", checksum: "valid-md5-domain-b" }, + files: [ + { + id: "/root", + name: "root", + type: NodeType.FOLDER, + children: [{ id: "/root/small.ts", name: "small.ts", type: NodeType.FILE }] + } + ], + lenses: { + metrics: { + attributes: { "/root/small.ts": { rloc: 30 } }, + attributeDescriptors: {}, + attributeTypes: { rloc: AttributeTypeValue.absolute } + }, + domain: { + "/root": [ + { text: "payment", frequency: 6, tfidf: 0.2 }, + { text: "shipping", frequency: 3 } + ], + "/root/small.ts": [{ text: "cart", frequency: 5 }] + } + } +} diff --git a/visualization/app/codeCharta/model/ccjson2.model.ts b/visualization/app/codeCharta/model/ccjson2.model.ts index b0de401c18..3e54b578f4 100644 --- a/visualization/app/codeCharta/model/ccjson2.model.ts +++ b/visualization/app/codeCharta/model/ccjson2.model.ts @@ -1,4 +1,12 @@ -import { AttributeDescriptors, AttributeTypeValue, BlacklistItem, FixedPosition, MarkedPackage, NodeType } from "./domain.model" +import { + AttributeDescriptors, + AttributeTypeValue, + BlacklistItem, + DomainLensData, + FixedPosition, + MarkedPackage, + NodeType +} from "./domain.model" export interface CcJson2 { meta: Meta2 @@ -25,6 +33,7 @@ export interface FileNode { interface Lenses { metrics?: MetricsLensData dependency?: DependencyLensData + domain?: DomainLensData /* * Opaque passthrough — the schema defines the clusters lens, but the viz neither reads nor * renders it yet. The typed model lands with the first producer. diff --git a/visualization/app/codeCharta/model/domain.model.ts b/visualization/app/codeCharta/model/domain.model.ts index db55b9dae6..be00d0328f 100644 --- a/visualization/app/codeCharta/model/domain.model.ts +++ b/visualization/app/codeCharta/model/domain.model.ts @@ -21,6 +21,8 @@ export interface CCFile { attributeDescriptors: AttributeDescriptors blacklist: Array markedPackages: Array + // The domain lens word bank, already re-keyed nodeId→path at load (mirrors `edges`). + domainWords: DomainLensData } } fileMeta: FileMeta @@ -118,6 +120,24 @@ export interface DependencyLensSource { attributeTypes: AttributeTypeMap } +/** + * The `domain` lens word bank, keyed by node PATH. The analyser emits it keyed by the 16-hex node id + * (see the analyser's `DomainProjectGenerator`); the reader re-keys nodeId→path at load — exactly as + * it does for dependency edges — because the viz addresses nodes by path and drops the analyser id. + */ +export type DomainLensData = Record + +export interface DomainWord { + text: string + frequency: number + tfidf?: number +} + +/** State home for the domain lens: the path-keyed word bank seeded from the loaded cc.json. */ +export interface DomainLensSource { + words: DomainLensData +} + export interface PrimaryMetrics { areaMetric: string heightMetric: string diff --git a/visualization/app/codeCharta/model/state.model.ts b/visualization/app/codeCharta/model/state.model.ts index bcf5f18862..e6286ae978 100644 --- a/visualization/app/codeCharta/model/state.model.ts +++ b/visualization/app/codeCharta/model/state.model.ts @@ -5,6 +5,7 @@ import { ColorMode, ColorRange, DependencyLensSource, + DomainLensSource, FileSettings, LabelMode, LayoutAlgorithm, @@ -15,6 +16,7 @@ import { Scaling, Sorting } from "./domain.model" +import { WordCloudSettings } from "./wordCloud.model" // The default number of top-value labels shown on the map. A plain domain default (not ngrx state), // so it lives in model/ where both the mapState amountOfTopLabels reducer and the pure @@ -98,6 +100,8 @@ export interface MapState extends PrimaryMetrics { export interface CcState { metricsLensSource: MetricsLensSource dependencyLensSource: DependencyLensSource + domainLensSource: DomainLensSource + domainBar: WordCloudSettings preferences: Preferences mapState: MapState sharedView: SharedView diff --git a/visualization/app/codeCharta/model/wordCloud.model.ts b/visualization/app/codeCharta/model/wordCloud.model.ts new file mode 100644 index 0000000000..f637374ae0 --- /dev/null +++ b/visualization/app/codeCharta/model/wordCloud.model.ts @@ -0,0 +1,57 @@ +// Word-cloud render settings. Kept in the model kernel (not the wordCloud feature) so both the +// stores/domainBar state home that owns them and the wordCloud renderer that consumes them can import +// them without a store→feature edge. + +/** The ECharts word-cloud layout shapes (see echarts-wordcloud). */ +export enum WordCloudShape { + circle = "circle", + cardioid = "cardioid", + diamond = "diamond", + triangle = "triangle", + pentagon = "pentagon", + star = "star" +} + +/** Which word metric drives a word's rendered size. `tfidf` is only offered when the data carries it. */ +export enum WordCloudSizingMode { + frequency = "frequency", + tfidf = "tfidf" +} + +/** + * The render controls the domain settings bar owns and the word-cloud renderer consumes. A pure value + * object so the option builder is unit-testable without a live chart (see wordCloudOption.builder). + */ +export interface WordCloudSettings { + shape: WordCloudShape + /** [min, max] font size in px. */ + sizeRange: [number, number] + /** [min, max] word rotation in degrees. */ + rotationRange: [number, number] + /** Rotation quantization step in degrees. */ + rotationStep: number + /** Layout grid spacing in px — larger means fewer, more spread-out words. */ + gridSize: number + sizingMode: WordCloudSizingMode + /** Keep only the top-N words by value. DLC default. */ + topN: number + /** + * What happens to a word the layout cannot place. When true it is shrunk (repeatedly, to 3/4 its + * weight) until it fits, so the cloud draws the requested word count — at the cost of tail words + * rendering below `sizeRange[0]`. When false the word is left out, so `sizeRange` is exact but + * fewer words than `topN` appear. + */ + shrinkToFit: boolean +} + +/** DLC-parity defaults for the word-cloud controls; the domain bar seeds its slices from these. */ +export const defaultWordCloudSettings: WordCloudSettings = { + shape: WordCloudShape.circle, + sizeRange: [12, 60], + rotationRange: [-90, 90], + rotationStep: 45, + gridSize: 8, + sizingMode: WordCloudSizingMode.frequency, + topN: 150, + shrinkToFit: false +} diff --git a/visualization/app/codeCharta/stores/domainBar/domainBar.read.facade.ts b/visualization/app/codeCharta/stores/domainBar/domainBar.read.facade.ts new file mode 100644 index 0000000000..7418267a7b --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/domainBar.read.facade.ts @@ -0,0 +1,2 @@ +export { DomainBarReadWindow } from "./store/domainBar.readWindow" +export { defaultDomainBar, domainBar } from "./store/domainBar.reducer" diff --git a/visualization/app/codeCharta/stores/domainBar/domainBar.write.facade.ts b/visualization/app/codeCharta/stores/domainBar/domainBar.write.facade.ts new file mode 100644 index 0000000000..113289ad46 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/domainBar.write.facade.ts @@ -0,0 +1,14 @@ +/** + * WRITE surface of the domainBar state home — the per-setting actions the domain settings bar dispatches. + * Kept separate from the read facade so a read-only consumer physically cannot dispatch; external access + * only via this facade (mirrors the mapState home split). + */ + +export { setDomainBarGridSize } from "./store/gridSize/gridSize.actions" +export { setDomainBarRotationRange } from "./store/rotationRange/rotationRange.actions" +export { setDomainBarRotationStep } from "./store/rotationStep/rotationStep.actions" +export { setDomainBarShape } from "./store/shape/shape.actions" +export { setDomainBarShrinkToFit } from "./store/shrinkToFit/shrinkToFit.actions" +export { setDomainBarSizeRange } from "./store/sizeRange/sizeRange.actions" +export { setDomainBarSizingMode } from "./store/sizingMode/sizingMode.actions" +export { setDomainBarTopN } from "./store/topN/topN.actions" diff --git a/visualization/app/codeCharta/stores/domainBar/store/domainBar.readWindow.spec.ts b/visualization/app/codeCharta/stores/domainBar/store/domainBar.readWindow.spec.ts new file mode 100644 index 0000000000..2577623b9c --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/domainBar.readWindow.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from "@angular/core/testing" +import { State } from "@ngrx/store" +import { provideMockStore } from "@ngrx/store/testing" +import { firstValueFrom } from "rxjs" +import { STATE } from "../../../mocks/dataMocks" +import { CcState } from "../../../model/codeCharta.model" +import { defaultWordCloudSettings, WordCloudShape } from "../../../model/wordCloud.model" +import { DomainBarReadWindow } from "./domainBar.readWindow" + +describe("DomainBarReadWindow", () => { + function setup(domainBar = defaultWordCloudSettings) { + const state: CcState = { ...STATE, domainBar } + TestBed.configureTestingModule({ + providers: [provideMockStore({ initialState: state }), { provide: State, useValue: { getValue: () => state } }] + }) + return TestBed.inject(DomainBarReadWindow) + } + + it("should read the current domain bar settings synchronously", () => { + // Arrange + const domainBar = { ...defaultWordCloudSettings, shape: WordCloudShape.diamond } + const readWindow = setup(domainBar) + + // Act & Assert + expect(readWindow.getDomainBar()).toEqual(domainBar) + }) + + it("should expose the composed word-cloud settings stream", async () => { + // Arrange + const readWindow = setup() + + // Act + const settings = await firstValueFrom(readWindow.wordCloudSettings$) + + // Assert + expect(settings).toEqual(defaultWordCloudSettings) + }) +}) diff --git a/visualization/app/codeCharta/stores/domainBar/store/domainBar.readWindow.ts b/visualization/app/codeCharta/stores/domainBar/store/domainBar.readWindow.ts new file mode 100644 index 0000000000..1250f56e52 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/domainBar.readWindow.ts @@ -0,0 +1,37 @@ +import { Injectable } from "@angular/core" +import { State, Store } from "@ngrx/store" +import { CcState } from "../../../model/codeCharta.model" +import { WordCloudSettings } from "../../../model/wordCloud.model" +import { gridSizeSelector } from "./gridSize/gridSize.selector" +import { rotationRangeSelector } from "./rotationRange/rotationRange.selector" +import { rotationStepSelector } from "./rotationStep/rotationStep.selector" +import { shapeSelector } from "./shape/shape.selector" +import { shrinkToFitSelector } from "./shrinkToFit/shrinkToFit.selector" +import { sizeRangeSelector } from "./sizeRange/sizeRange.selector" +import { sizingModeSelector } from "./sizingMode/sizingMode.selector" +import { topNSelector } from "./topN/topN.selector" +import { wordCloudSettingsSelector } from "./wordCloudSettings.selector" + +@Injectable({ + providedIn: "root" +}) +export class DomainBarReadWindow { + constructor( + private readonly store: Store, + private readonly state: State + ) {} + + readonly shape$ = this.store.select(shapeSelector) + readonly sizeRange$ = this.store.select(sizeRangeSelector) + readonly rotationRange$ = this.store.select(rotationRangeSelector) + readonly rotationStep$ = this.store.select(rotationStepSelector) + readonly gridSize$ = this.store.select(gridSizeSelector) + readonly sizingMode$ = this.store.select(sizingModeSelector) + readonly topN$ = this.store.select(topNSelector) + readonly shrinkToFit$ = this.store.select(shrinkToFitSelector) + readonly wordCloudSettings$ = this.store.select(wordCloudSettingsSelector) + + getDomainBar(): WordCloudSettings { + return this.state.getValue().domainBar + } +} diff --git a/visualization/app/codeCharta/stores/domainBar/store/domainBar.reducer.ts b/visualization/app/codeCharta/stores/domainBar/store/domainBar.reducer.ts new file mode 100644 index 0000000000..914e317382 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/domainBar.reducer.ts @@ -0,0 +1,34 @@ +import { combineReducers } from "@ngrx/store" +import { WordCloudSettings } from "../../../model/wordCloud.model" +import { defaultGridSize, gridSize } from "./gridSize/gridSize.reducer" +import { defaultRotationRange, rotationRange } from "./rotationRange/rotationRange.reducer" +import { defaultRotationStep, rotationStep } from "./rotationStep/rotationStep.reducer" +import { defaultShape, shape } from "./shape/shape.reducer" +import { defaultShrinkToFit, shrinkToFit } from "./shrinkToFit/shrinkToFit.reducer" +import { defaultSizeRange, sizeRange } from "./sizeRange/sizeRange.reducer" +import { defaultSizingMode, sizingMode } from "./sizingMode/sizingMode.reducer" +import { defaultTopN, topN } from "./topN/topN.reducer" + +// The domain settings bar's persisted render controls for the word cloud. Per-setting slices mirror the +// mapState home so each control persists (via the whole-CcState indexedDBWriter) and resets independently. +export const domainBar = combineReducers({ + shape, + sizeRange, + rotationRange, + rotationStep, + gridSize, + sizingMode, + topN, + shrinkToFit +}) + +export const defaultDomainBar: WordCloudSettings = { + shape: defaultShape, + sizeRange: defaultSizeRange, + rotationRange: defaultRotationRange, + rotationStep: defaultRotationStep, + gridSize: defaultGridSize, + sizingMode: defaultSizingMode, + topN: defaultTopN, + shrinkToFit: defaultShrinkToFit +} diff --git a/visualization/app/codeCharta/stores/domainBar/store/domainBar.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/domainBar.selector.ts new file mode 100644 index 0000000000..55f30850e3 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/domainBar.selector.ts @@ -0,0 +1,3 @@ +import { CcState } from "../../../model/codeCharta.model" + +export const domainBarSelector = (state: CcState) => state.domainBar diff --git a/visualization/app/codeCharta/stores/domainBar/store/domainBar.spec.ts b/visualization/app/codeCharta/stores/domainBar/store/domainBar.spec.ts new file mode 100644 index 0000000000..d451d773df --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/domainBar.spec.ts @@ -0,0 +1,90 @@ +import { STATE } from "../../../mocks/dataMocks" +import { CcState } from "../../../model/codeCharta.model" +import { defaultWordCloudSettings, WordCloudShape, WordCloudSizingMode } from "../../../model/wordCloud.model" +import { setDomainBarGridSize } from "./gridSize/gridSize.actions" +import { gridSize } from "./gridSize/gridSize.reducer" +import { setDomainBarRotationRange } from "./rotationRange/rotationRange.actions" +import { rotationRange } from "./rotationRange/rotationRange.reducer" +import { setDomainBarRotationStep } from "./rotationStep/rotationStep.actions" +import { rotationStep } from "./rotationStep/rotationStep.reducer" +import { setDomainBarShape } from "./shape/shape.actions" +import { shape } from "./shape/shape.reducer" +import { setDomainBarSizeRange } from "./sizeRange/sizeRange.actions" +import { sizeRange } from "./sizeRange/sizeRange.reducer" +import { setDomainBarSizingMode } from "./sizingMode/sizingMode.actions" +import { sizingMode } from "./sizingMode/sizingMode.reducer" +import { setDomainBarTopN } from "./topN/topN.actions" +import { topN } from "./topN/topN.reducer" +import { wordCloudSettingsSelector } from "./wordCloudSettings.selector" + +describe("domainBar store", () => { + describe("shape reducer", () => { + it("should set the shape", () => { + expect(shape(defaultWordCloudSettings.shape, setDomainBarShape({ value: WordCloudShape.star }))).toBe(WordCloudShape.star) + }) + + it("should reset to the default when the payload is undefined", () => { + const value = undefined as unknown as WordCloudShape + expect(shape(WordCloudShape.star, setDomainBarShape({ value }))).toBe(defaultWordCloudSettings.shape) + }) + }) + + describe("sizeRange reducer", () => { + it("should set the size range", () => { + expect(sizeRange(defaultWordCloudSettings.sizeRange, setDomainBarSizeRange({ value: [10, 40] }))).toEqual([10, 40]) + }) + }) + + describe("rotationRange reducer", () => { + it("should set the rotation range", () => { + expect(rotationRange(defaultWordCloudSettings.rotationRange, setDomainBarRotationRange({ value: [0, 0] }))).toEqual([0, 0]) + }) + }) + + describe("rotationStep reducer", () => { + it("should set the rotation step", () => { + expect(rotationStep(defaultWordCloudSettings.rotationStep, setDomainBarRotationStep({ value: 15 }))).toBe(15) + }) + }) + + describe("gridSize reducer", () => { + it("should set the grid size", () => { + expect(gridSize(defaultWordCloudSettings.gridSize, setDomainBarGridSize({ value: 20 }))).toBe(20) + }) + }) + + describe("sizingMode reducer", () => { + it("should set the sizing mode", () => { + expect(sizingMode(defaultWordCloudSettings.sizingMode, setDomainBarSizingMode({ value: WordCloudSizingMode.tfidf }))).toBe( + WordCloudSizingMode.tfidf + ) + }) + }) + + describe("topN reducer", () => { + it("should set the top-N", () => { + expect(topN(defaultWordCloudSettings.topN, setDomainBarTopN({ value: 42 }))).toBe(42) + }) + + it("should reset to the default when the payload is undefined", () => { + const value = undefined as unknown as number + expect(topN(42, setDomainBarTopN({ value }))).toBe(defaultWordCloudSettings.topN) + }) + }) + + describe("wordCloudSettingsSelector", () => { + it("should compose the domain bar slices into a WordCloudSettings", () => { + // Arrange + const state: CcState = { + ...STATE, + domainBar: { ...defaultWordCloudSettings, shape: WordCloudShape.diamond, topN: 25 } + } + + // Act + const result = wordCloudSettingsSelector(state) + + // Assert + expect(result).toEqual({ ...defaultWordCloudSettings, shape: WordCloudShape.diamond, topN: 25 }) + }) + }) +}) diff --git a/visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.actions.ts b/visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.actions.ts new file mode 100644 index 0000000000..9233dcdab2 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.actions.ts @@ -0,0 +1,4 @@ +import { createAction, props } from "@ngrx/store" +import { WordCloudSettings } from "../../../../model/wordCloud.model" + +export const setDomainBarGridSize = createAction("SET_DOMAIN_BAR_GRID_SIZE", props<{ value: WordCloudSettings["gridSize"] }>()) diff --git a/visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.reducer.ts b/visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.reducer.ts new file mode 100644 index 0000000000..393a156d6e --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.reducer.ts @@ -0,0 +1,7 @@ +import { createReducer, on } from "@ngrx/store" +import { defaultWordCloudSettings, WordCloudSettings } from "../../../../model/wordCloud.model" +import { setState } from "../../../../util/setState.reducer.factory" +import { setDomainBarGridSize } from "./gridSize.actions" + +export const defaultGridSize: WordCloudSettings["gridSize"] = defaultWordCloudSettings.gridSize +export const gridSize = createReducer(defaultGridSize, on(setDomainBarGridSize, setState(defaultGridSize))) diff --git a/visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.selector.ts new file mode 100644 index 0000000000..170a39eebb --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/gridSize/gridSize.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from "@ngrx/store" +import { domainBarSelector } from "../domainBar.selector" + +export const gridSizeSelector = createSelector(domainBarSelector, domainBar => domainBar.gridSize) diff --git a/visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.actions.ts b/visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.actions.ts new file mode 100644 index 0000000000..1317d948e3 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.actions.ts @@ -0,0 +1,7 @@ +import { createAction, props } from "@ngrx/store" +import { WordCloudSettings } from "../../../../model/wordCloud.model" + +export const setDomainBarRotationRange = createAction( + "SET_DOMAIN_BAR_ROTATION_RANGE", + props<{ value: WordCloudSettings["rotationRange"] }>() +) diff --git a/visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.reducer.ts b/visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.reducer.ts new file mode 100644 index 0000000000..2a569d19d9 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.reducer.ts @@ -0,0 +1,7 @@ +import { createReducer, on } from "@ngrx/store" +import { defaultWordCloudSettings, WordCloudSettings } from "../../../../model/wordCloud.model" +import { setState } from "../../../../util/setState.reducer.factory" +import { setDomainBarRotationRange } from "./rotationRange.actions" + +export const defaultRotationRange: WordCloudSettings["rotationRange"] = defaultWordCloudSettings.rotationRange +export const rotationRange = createReducer(defaultRotationRange, on(setDomainBarRotationRange, setState(defaultRotationRange))) diff --git a/visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.selector.ts new file mode 100644 index 0000000000..9551992a8c --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/rotationRange/rotationRange.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from "@ngrx/store" +import { domainBarSelector } from "../domainBar.selector" + +export const rotationRangeSelector = createSelector(domainBarSelector, domainBar => domainBar.rotationRange) diff --git a/visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.actions.ts b/visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.actions.ts new file mode 100644 index 0000000000..466d04169c --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.actions.ts @@ -0,0 +1,4 @@ +import { createAction, props } from "@ngrx/store" +import { WordCloudSettings } from "../../../../model/wordCloud.model" + +export const setDomainBarRotationStep = createAction("SET_DOMAIN_BAR_ROTATION_STEP", props<{ value: WordCloudSettings["rotationStep"] }>()) diff --git a/visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.reducer.ts b/visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.reducer.ts new file mode 100644 index 0000000000..8f161baf3d --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.reducer.ts @@ -0,0 +1,7 @@ +import { createReducer, on } from "@ngrx/store" +import { defaultWordCloudSettings, WordCloudSettings } from "../../../../model/wordCloud.model" +import { setState } from "../../../../util/setState.reducer.factory" +import { setDomainBarRotationStep } from "./rotationStep.actions" + +export const defaultRotationStep: WordCloudSettings["rotationStep"] = defaultWordCloudSettings.rotationStep +export const rotationStep = createReducer(defaultRotationStep, on(setDomainBarRotationStep, setState(defaultRotationStep))) diff --git a/visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.selector.ts new file mode 100644 index 0000000000..85aa930576 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/rotationStep/rotationStep.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from "@ngrx/store" +import { domainBarSelector } from "../domainBar.selector" + +export const rotationStepSelector = createSelector(domainBarSelector, domainBar => domainBar.rotationStep) diff --git a/visualization/app/codeCharta/stores/domainBar/store/shape/shape.actions.ts b/visualization/app/codeCharta/stores/domainBar/store/shape/shape.actions.ts new file mode 100644 index 0000000000..7a142e15e7 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/shape/shape.actions.ts @@ -0,0 +1,4 @@ +import { createAction, props } from "@ngrx/store" +import { WordCloudSettings } from "../../../../model/wordCloud.model" + +export const setDomainBarShape = createAction("SET_DOMAIN_BAR_SHAPE", props<{ value: WordCloudSettings["shape"] }>()) diff --git a/visualization/app/codeCharta/stores/domainBar/store/shape/shape.reducer.ts b/visualization/app/codeCharta/stores/domainBar/store/shape/shape.reducer.ts new file mode 100644 index 0000000000..6077fdf101 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/shape/shape.reducer.ts @@ -0,0 +1,7 @@ +import { createReducer, on } from "@ngrx/store" +import { defaultWordCloudSettings, WordCloudSettings } from "../../../../model/wordCloud.model" +import { setState } from "../../../../util/setState.reducer.factory" +import { setDomainBarShape } from "./shape.actions" + +export const defaultShape: WordCloudSettings["shape"] = defaultWordCloudSettings.shape +export const shape = createReducer(defaultShape, on(setDomainBarShape, setState(defaultShape))) diff --git a/visualization/app/codeCharta/stores/domainBar/store/shape/shape.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/shape/shape.selector.ts new file mode 100644 index 0000000000..b8ec5938e5 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/shape/shape.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from "@ngrx/store" +import { domainBarSelector } from "../domainBar.selector" + +export const shapeSelector = createSelector(domainBarSelector, domainBar => domainBar.shape) diff --git a/visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.actions.ts b/visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.actions.ts new file mode 100644 index 0000000000..eb78f653c5 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.actions.ts @@ -0,0 +1,4 @@ +import { createAction, props } from "@ngrx/store" +import { WordCloudSettings } from "../../../../model/wordCloud.model" + +export const setDomainBarShrinkToFit = createAction("SET_DOMAIN_BAR_SHRINK_TO_FIT", props<{ value: WordCloudSettings["shrinkToFit"] }>()) diff --git a/visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.reducer.ts b/visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.reducer.ts new file mode 100644 index 0000000000..e8eb48601a --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.reducer.ts @@ -0,0 +1,7 @@ +import { createReducer, on } from "@ngrx/store" +import { defaultWordCloudSettings, WordCloudSettings } from "../../../../model/wordCloud.model" +import { setState } from "../../../../util/setState.reducer.factory" +import { setDomainBarShrinkToFit } from "./shrinkToFit.actions" + +export const defaultShrinkToFit: WordCloudSettings["shrinkToFit"] = defaultWordCloudSettings.shrinkToFit +export const shrinkToFit = createReducer(defaultShrinkToFit, on(setDomainBarShrinkToFit, setState(defaultShrinkToFit))) diff --git a/visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.selector.ts new file mode 100644 index 0000000000..07de27ec0a --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/shrinkToFit/shrinkToFit.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from "@ngrx/store" +import { domainBarSelector } from "../domainBar.selector" + +export const shrinkToFitSelector = createSelector(domainBarSelector, domainBar => domainBar.shrinkToFit) diff --git a/visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.actions.ts b/visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.actions.ts new file mode 100644 index 0000000000..5b7e21c407 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.actions.ts @@ -0,0 +1,4 @@ +import { createAction, props } from "@ngrx/store" +import { WordCloudSettings } from "../../../../model/wordCloud.model" + +export const setDomainBarSizeRange = createAction("SET_DOMAIN_BAR_SIZE_RANGE", props<{ value: WordCloudSettings["sizeRange"] }>()) diff --git a/visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.reducer.ts b/visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.reducer.ts new file mode 100644 index 0000000000..cbcc379324 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.reducer.ts @@ -0,0 +1,7 @@ +import { createReducer, on } from "@ngrx/store" +import { defaultWordCloudSettings, WordCloudSettings } from "../../../../model/wordCloud.model" +import { setState } from "../../../../util/setState.reducer.factory" +import { setDomainBarSizeRange } from "./sizeRange.actions" + +export const defaultSizeRange: WordCloudSettings["sizeRange"] = defaultWordCloudSettings.sizeRange +export const sizeRange = createReducer(defaultSizeRange, on(setDomainBarSizeRange, setState(defaultSizeRange))) diff --git a/visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.selector.ts new file mode 100644 index 0000000000..ec9e6d3311 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/sizeRange/sizeRange.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from "@ngrx/store" +import { domainBarSelector } from "../domainBar.selector" + +export const sizeRangeSelector = createSelector(domainBarSelector, domainBar => domainBar.sizeRange) diff --git a/visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.actions.ts b/visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.actions.ts new file mode 100644 index 0000000000..9c53357261 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.actions.ts @@ -0,0 +1,4 @@ +import { createAction, props } from "@ngrx/store" +import { WordCloudSettings } from "../../../../model/wordCloud.model" + +export const setDomainBarSizingMode = createAction("SET_DOMAIN_BAR_SIZING_MODE", props<{ value: WordCloudSettings["sizingMode"] }>()) diff --git a/visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.reducer.ts b/visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.reducer.ts new file mode 100644 index 0000000000..83bd273c66 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.reducer.ts @@ -0,0 +1,7 @@ +import { createReducer, on } from "@ngrx/store" +import { defaultWordCloudSettings, WordCloudSettings } from "../../../../model/wordCloud.model" +import { setState } from "../../../../util/setState.reducer.factory" +import { setDomainBarSizingMode } from "./sizingMode.actions" + +export const defaultSizingMode: WordCloudSettings["sizingMode"] = defaultWordCloudSettings.sizingMode +export const sizingMode = createReducer(defaultSizingMode, on(setDomainBarSizingMode, setState(defaultSizingMode))) diff --git a/visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.selector.ts new file mode 100644 index 0000000000..888e1b3d43 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/sizingMode/sizingMode.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from "@ngrx/store" +import { domainBarSelector } from "../domainBar.selector" + +export const sizingModeSelector = createSelector(domainBarSelector, domainBar => domainBar.sizingMode) diff --git a/visualization/app/codeCharta/stores/domainBar/store/topN/topN.actions.ts b/visualization/app/codeCharta/stores/domainBar/store/topN/topN.actions.ts new file mode 100644 index 0000000000..457d4efd22 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/topN/topN.actions.ts @@ -0,0 +1,4 @@ +import { createAction, props } from "@ngrx/store" +import { WordCloudSettings } from "../../../../model/wordCloud.model" + +export const setDomainBarTopN = createAction("SET_DOMAIN_BAR_TOP_N", props<{ value: WordCloudSettings["topN"] }>()) diff --git a/visualization/app/codeCharta/stores/domainBar/store/topN/topN.reducer.ts b/visualization/app/codeCharta/stores/domainBar/store/topN/topN.reducer.ts new file mode 100644 index 0000000000..79f02d5209 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/topN/topN.reducer.ts @@ -0,0 +1,7 @@ +import { createReducer, on } from "@ngrx/store" +import { defaultWordCloudSettings, WordCloudSettings } from "../../../../model/wordCloud.model" +import { setState } from "../../../../util/setState.reducer.factory" +import { setDomainBarTopN } from "./topN.actions" + +export const defaultTopN: WordCloudSettings["topN"] = defaultWordCloudSettings.topN +export const topN = createReducer(defaultTopN, on(setDomainBarTopN, setState(defaultTopN))) diff --git a/visualization/app/codeCharta/stores/domainBar/store/topN/topN.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/topN/topN.selector.ts new file mode 100644 index 0000000000..6007d6e792 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/topN/topN.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from "@ngrx/store" +import { domainBarSelector } from "../domainBar.selector" + +export const topNSelector = createSelector(domainBarSelector, domainBar => domainBar.topN) diff --git a/visualization/app/codeCharta/stores/domainBar/store/wordCloudSettings.selector.ts b/visualization/app/codeCharta/stores/domainBar/store/wordCloudSettings.selector.ts new file mode 100644 index 0000000000..d09dfc07f5 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainBar/store/wordCloudSettings.selector.ts @@ -0,0 +1,18 @@ +import { createSelector } from "@ngrx/store" +import { WordCloudSettings } from "../../../model/wordCloud.model" +import { domainBarSelector } from "./domainBar.selector" + +/** The domain bar's controls composed into the WordCloudSettings the renderer consumes. */ +export const wordCloudSettingsSelector = createSelector( + domainBarSelector, + (domainBar): WordCloudSettings => ({ + shape: domainBar.shape, + sizeRange: domainBar.sizeRange, + rotationRange: domainBar.rotationRange, + rotationStep: domainBar.rotationStep, + gridSize: domainBar.gridSize, + sizingMode: domainBar.sizingMode, + topN: domainBar.topN, + shrinkToFit: domainBar.shrinkToFit + }) +) diff --git a/visualization/app/codeCharta/stores/domainLensSource/domainLensSource.read.facade.ts b/visualization/app/codeCharta/stores/domainLensSource/domainLensSource.read.facade.ts new file mode 100644 index 0000000000..f00d33d508 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/domainLensSource.read.facade.ts @@ -0,0 +1,3 @@ +export { DomainLensSourceReadWindow } from "./store/domainLensSource.readWindow" +export { defaultDomainLensSource, domainLensSource } from "./store/domainLensSource.reducer" +export { domainWordsSelector } from "./store/words/words.selector" diff --git a/visualization/app/codeCharta/stores/domainLensSource/domainLensSource.write.facade.ts b/visualization/app/codeCharta/stores/domainLensSource/domainLensSource.write.facade.ts new file mode 100644 index 0000000000..46ca40cafd --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/domainLensSource.write.facade.ts @@ -0,0 +1,6 @@ +/** + * WRITE surface of the domainLensSource state home — the seed action the load pipeline dispatches to + * populate the cc.json domain lens word bank on file load. Kept separate from the read facade so a + * read-only consumer physically cannot dispatch. External access only via this facade (stores-own-ccjson-source). + */ +export { setDomainWords } from "./store/words/words.actions" diff --git a/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.readWindow.spec.ts b/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.readWindow.spec.ts new file mode 100644 index 0000000000..c832ba8b72 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.readWindow.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from "@angular/core/testing" +import { State } from "@ngrx/store" +import { provideMockStore } from "@ngrx/store/testing" +import { firstValueFrom } from "rxjs" +import { STATE } from "../../../mocks/dataMocks" +import { CcState, DomainLensData } from "../../../model/codeCharta.model" +import { DomainLensSourceReadWindow } from "./domainLensSource.readWindow" + +describe("DomainLensSourceReadWindow", () => { + const words: DomainLensData = { "/root": [{ text: "invoice", frequency: 12 }] } + + function setup() { + const state: CcState = { ...STATE, domainLensSource: { words } } + TestBed.configureTestingModule({ + providers: [provideMockStore({ initialState: state }), { provide: State, useValue: { getValue: () => state } }] + }) + return TestBed.inject(DomainLensSourceReadWindow) + } + + it("should read the current domain words synchronously", () => { + // Arrange + const readWindow = setup() + + // Act & Assert + expect(readWindow.getDomainWords()).toEqual(words) + expect(readWindow.getDomainLensSource()).toEqual({ words }) + }) + + it("should expose the domain words stream", async () => { + // Arrange + const readWindow = setup() + + // Act + const result = await firstValueFrom(readWindow.domainWords$) + + // Assert + expect(result).toEqual(words) + }) +}) diff --git a/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.readWindow.ts b/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.readWindow.ts new file mode 100644 index 0000000000..f8969c9f2c --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.readWindow.ts @@ -0,0 +1,24 @@ +import { Injectable } from "@angular/core" +import { State, Store } from "@ngrx/store" +import { CcState, DomainLensData, DomainLensSource } from "../../../model/codeCharta.model" +import { domainWordsSelector } from "./words/words.selector" + +@Injectable({ + providedIn: "root" +}) +export class DomainLensSourceReadWindow { + constructor( + private readonly store: Store, + private readonly state: State + ) {} + + readonly domainWords$ = this.store.select(domainWordsSelector) + + getDomainLensSource(): DomainLensSource { + return this.state.getValue().domainLensSource + } + + getDomainWords(): DomainLensData { + return this.state.getValue().domainLensSource.words + } +} diff --git a/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.reducer.ts b/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.reducer.ts new file mode 100644 index 0000000000..23c11a43dd --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.reducer.ts @@ -0,0 +1,14 @@ +import { combineReducers } from "@ngrx/store" +import { DomainLensSource } from "../../../model/codeCharta.model" +import { defaultDomainWords, domainWords } from "./words/words.reducer" + +// The domain lens's cc.json SOURCE root: the path-keyed word bank, seeded from the loaded cc.json's +// `domain` lens (re-keyed nodeId→path at load). Unlike the metrics/dependency sources it carries bulk +// per-node data rather than attribute metadata, but it is wired the same way through the load pipeline. +export const domainLensSource = combineReducers({ + words: domainWords +}) + +export const defaultDomainLensSource: DomainLensSource = { + words: defaultDomainWords +} diff --git a/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.selector.ts b/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.selector.ts new file mode 100644 index 0000000000..1e66222dfc --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/store/domainLensSource.selector.ts @@ -0,0 +1,3 @@ +import { CcState } from "../../../model/codeCharta.model" + +export const domainLensSourceSelector = (state: CcState) => state.domainLensSource diff --git a/visualization/app/codeCharta/stores/domainLensSource/store/words/words.actions.ts b/visualization/app/codeCharta/stores/domainLensSource/store/words/words.actions.ts new file mode 100644 index 0000000000..b06f3df04c --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/store/words/words.actions.ts @@ -0,0 +1,4 @@ +import { createAction, props } from "@ngrx/store" +import { DomainLensData } from "../../../../model/codeCharta.model" + +export const setDomainWords = createAction("SET_DOMAIN_WORDS", props<{ value: DomainLensData }>()) diff --git a/visualization/app/codeCharta/stores/domainLensSource/store/words/words.reducer.spec.ts b/visualization/app/codeCharta/stores/domainLensSource/store/words/words.reducer.spec.ts new file mode 100644 index 0000000000..7fee11bfd4 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/store/words/words.reducer.spec.ts @@ -0,0 +1,31 @@ +import { DomainLensData } from "../../../../model/codeCharta.model" +import { setDomainWords } from "./words.actions" +import { domainWords } from "./words.reducer" + +describe("domainWords", () => { + const defaultValue: DomainLensData = {} + + describe("Action: SET_DOMAIN_WORDS", () => { + it("should set new domain words", () => { + // Arrange + const value: DomainLensData = { "/root": [{ text: "invoice", frequency: 12, tfidf: 0.4 }] } + + // Act + const result = domainWords(defaultValue, setDomainWords({ value })) + + // Assert + expect(result).toEqual(value) + }) + + it("should reset to the default when the payload is undefined", () => { + // Arrange + const value = undefined as unknown as DomainLensData + + // Act + const result = domainWords({ "/root": [{ text: "invoice", frequency: 1 }] }, setDomainWords({ value })) + + // Assert + expect(result).toEqual(defaultValue) + }) + }) +}) diff --git a/visualization/app/codeCharta/stores/domainLensSource/store/words/words.reducer.ts b/visualization/app/codeCharta/stores/domainLensSource/store/words/words.reducer.ts new file mode 100644 index 0000000000..9739be4983 --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/store/words/words.reducer.ts @@ -0,0 +1,7 @@ +import { createReducer, on } from "@ngrx/store" +import { DomainLensData } from "../../../../model/codeCharta.model" +import { setState } from "../../../../util/setState.reducer.factory" +import { setDomainWords } from "./words.actions" + +export const defaultDomainWords: DomainLensData = {} +export const domainWords = createReducer(defaultDomainWords, on(setDomainWords, setState(defaultDomainWords))) diff --git a/visualization/app/codeCharta/stores/domainLensSource/store/words/words.selector.ts b/visualization/app/codeCharta/stores/domainLensSource/store/words/words.selector.ts new file mode 100644 index 0000000000..5b70563bac --- /dev/null +++ b/visualization/app/codeCharta/stores/domainLensSource/store/words/words.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from "@ngrx/store" +import { domainLensSourceSelector } from "../domainLensSource.selector" + +export const domainWordsSelector = createSelector(domainLensSourceSelector, source => source.words) diff --git a/visualization/app/codeCharta/stores/fileStore/fileStore.facade.ts b/visualization/app/codeCharta/stores/fileStore/fileStore.facade.ts index c060962278..b84a5e554f 100644 --- a/visualization/app/codeCharta/stores/fileStore/fileStore.facade.ts +++ b/visualization/app/codeCharta/stores/fileStore/fileStore.facade.ts @@ -29,4 +29,4 @@ export { filesLoaded } from "./store/filesLoaded/filesLoaded.actions" export { isDeltaStateSelector } from "./store/isDeltaState.selector" export { setIsLoadingFile } from "./store/isLoadingFile/isLoadingFile.actions" export { defaultIsLoadingFile, isLoadingFile } from "./store/isLoadingFile/isLoadingFile.reducer" -export { visibleFileStatesSelector } from "./store/visibleFileStates.selector" +export { visibleFileStatesSelector, visibleFileStatesWithCurrentSettingsSelector } from "./store/visibleFileStates.selector" diff --git a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/services/loadFile.service.spec.ts b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/services/loadFile.service.spec.ts index 5e8d8ecd3c..3670fe6745 100644 --- a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/services/loadFile.service.spec.ts +++ b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/services/loadFile.service.spec.ts @@ -123,7 +123,8 @@ describe("loadFileService", () => { attributeDescriptors: {}, blacklist: [], edges: [], - markedPackages: [] + markedPackages: [], + domainWords: {} } } } diff --git a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/__snapshots__/ccFileHelper.spec.ts.snap b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/__snapshots__/ccFileHelper.spec.ts.snap index a963ce2ddf..db09fb16f7 100644 --- a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/__snapshots__/ccFileHelper.spec.ts.snap +++ b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/__snapshots__/ccFileHelper.spec.ts.snap @@ -60,6 +60,7 @@ exports[`ccFileHelper getCCFile should build a CCFile 1`] = ` "nodes": {}, }, "blacklist": [], + "domainWords": {}, "edges": [], "markedPackages": [], }, diff --git a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/ccJson2/ccJson2ToCCFile.spec.ts b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/ccJson2/ccJson2ToCCFile.spec.ts index ea217c9a34..ee223fbe8a 100644 --- a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/ccJson2/ccJson2ToCCFile.spec.ts +++ b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/ccJson2/ccJson2ToCCFile.spec.ts @@ -83,6 +83,48 @@ describe("mapCcJson2ToCCFile", () => { ]) }) + it("should re-key domain words from node id to node path", () => { + // Arrange + ;(file.lenses as Record).domain = { + "/root": [{ text: "invoice", frequency: 12, tfidf: 0.4 }], + "/root/big.ts": [{ text: "payment", frequency: 5 }] + } + + // Act + const result = mapCcJson2ToCCFile(file, nameDataPair(file)) + + // Assert + expect(result.settings.fileSettings.domainWords).toEqual({ + "/root": [{ text: "invoice", frequency: 12, tfidf: 0.4 }], + "/root/big.ts": [{ text: "payment", frequency: 5 }] + }) + }) + + it("should drop domain words whose node id does not resolve to a path", () => { + // Arrange + const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined) + ;(file.lenses as Record).domain = { + "/root/big.ts": [{ text: "payment", frequency: 5 }], + "/does/not/exist": [{ text: "ghost", frequency: 1 }] + } + + // Act + const result = mapCcJson2ToCCFile(file, nameDataPair(file)) + + // Assert + expect(result.settings.fileSettings.domainWords).toEqual({ "/root/big.ts": [{ text: "payment", frequency: 5 }] }) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it("should default domain words to an empty map when the file has no domain lens", () => { + // Act + const result = mapCcJson2ToCCFile(file, nameDataPair(file)) + + // Assert + expect(result.settings.fileSettings.domainWords).toEqual({}) + }) + it("should drop edges with an unresolved endpoint", () => { // Arrange const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined) diff --git a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/ccJson2/ccJson2ToCCFile.ts b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/ccJson2/ccJson2ToCCFile.ts index 90ff231f1e..27ea700f62 100644 --- a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/ccJson2/ccJson2ToCCFile.ts +++ b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/ccJson2/ccJson2ToCCFile.ts @@ -6,6 +6,7 @@ import { AttributeTypes, CCFile, CodeMapNode, + DomainLensData, Edge, KeyValuePair } from "../../../../../../model/codeCharta.model" @@ -29,6 +30,7 @@ export function mapCcJson2ToCCFile(file: CcJson2WithCarryover, nameDataPair: Nam edges: mapEdges(file, idToPath), attributeTypes: getAttributeTypes(file), attributeDescriptors: getAttributeDescriptors(file), + domainWords: mapDomainWords(file, idToPath), // 2.0 files carry neither; both are populated only when a 1.x file is normalized. blacklist: file.blacklist ?? [], markedPackages: file.markedPackages ?? [] @@ -90,6 +92,24 @@ function mapEdges(file: CcJson2, idToPath: Record): Edge[] { return edges } +/** + * Re-keys the `domain` lens from the analyser's 16-hex node id to the viz path — the viz drops the id + * at load and addresses nodes by path, so the words must ride the same `idToPath` the edges do. A word + * list whose node id has no path (e.g. a stale key) is dropped with a warning, as an edge would be. + */ +function mapDomainWords(file: CcJson2, idToPath: Record): DomainLensData { + const domainWords: DomainLensData = {} + for (const [nodeId, words] of Object.entries(file.lenses.domain ?? {})) { + const path = idToPath[nodeId] + if (path === undefined) { + console.warn(`Dropping domain-lens words with unresolved node id: ${nodeId}`) + continue + } + domainWords[path] = words + } + return domainWords +} + function getAttributeTypes(file: CcJson2): AttributeTypes { return { nodes: file.lenses.metrics?.attributeTypes ?? {}, diff --git a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/fileValidator.spec.ts b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/fileValidator.spec.ts index 50daafa6af..772ae4090a 100644 --- a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/fileValidator.spec.ts +++ b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/fileValidator.spec.ts @@ -328,6 +328,54 @@ describe("FileValidator", () => { expect(checkWarnings(file2_0)).toEqual([]) }) + it("should accept a 2.0 file carrying a domain lens", () => { + // Arrange + file2_0.lenses.domain = { + "/root": [{ text: "invoice", frequency: 12, tfidf: 0.4 }], + "/root/big.ts": [{ text: "payment", frequency: 5 }] + } + + // Act + const errors = checkErrors(file2_0) + + // Assert + expect(isCcJson2(file2_0)).toBe(true) + expect(errors).toEqual([]) + }) + + it("should report an error for a domain lens whose word bank is not an array", () => { + // Arrange + ;(file2_0.lenses as Record).domain = { "/root": { text: "invoice", frequency: 12 } } + + // Act + const errors = checkErrors(file2_0) + + // Assert + expect(errors).toEqual(["Type error: lenses/domain/~1root must be array"]) + }) + + it("should report an error for a domain word missing its required fields", () => { + // Arrange + ;(file2_0.lenses as Record).domain = { "/root": [{ text: "invoice" }] } + + // Act + const errors = checkErrors(file2_0) + + // Assert + expect(errors).toEqual(["Required error: lenses/domain/~1root/0 must have required property 'frequency'"]) + }) + + it("should report an error for a domain word whose frequency is not a number", () => { + // Arrange + ;(file2_0.lenses as Record).domain = { "/root": [{ text: "invoice", frequency: "many" }] } + + // Act + const errors = checkErrors(file2_0) + + // Assert + expect(errors).toEqual(["Type error: lenses/domain/~1root/0/frequency must be number"]) + }) + it("should warn about a 2.0 dependency edge whose to endpoint id does not resolve to a node", () => { file2_0.lenses.dependency.edges.push({ fromId: "/root/big.ts", toId: "/does/not/exist", attributes: {} }) @@ -340,6 +388,24 @@ describe("FileValidator", () => { expect(checkWarnings(file2_0)).toEqual([`${ERROR_MESSAGES.unresolvedEdgeEndpoint} /does/not/exist -> /root/big.ts`]) }) + it("should warn about 2.0 domain-lens word banks whose node ids do not resolve to a node", () => { + // Arrange + file2_0.lenses.domain = { + "/root/big.ts": [{ text: "payment", frequency: 5 }], + "/does/not/exist": [{ text: "invoice", frequency: 12 }], + "/gone/too": [{ text: "refund", frequency: 3 }] + } + + // Act + const warnings = checkWarnings(file2_0) + + // Assert + expect(warnings).toEqual([ + `${ERROR_MESSAGES.unresolvedDomainWordsNodeId} /does/not/exist`, + `${ERROR_MESSAGES.unresolvedDomainWordsNodeId} /gone/too` + ]) + }) + it("should report an error for two 2.0 sibling nodes sharing a name and type but with distinct ids", () => { file2_0.files[0].children.push({ id: "/root/big.ts#dup", name: "big.ts", type: NodeType.FILE }) diff --git a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/fileValidator.ts b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/fileValidator.ts index 49692063a0..9b7c11c7a3 100644 --- a/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/fileValidator.ts +++ b/visualization/app/codeCharta/stores/fileStore/loaders/ccJson/util/fileValidator.ts @@ -28,6 +28,7 @@ export const ERROR_MESSAGES = { nodesNotUnique: "Node names in combination with node types are not unique.", nodeIdsNotUnique: "Node ids are not unique.", unresolvedEdgeEndpoint: "Dependency edge dropped: an endpoint id does not resolve to a node.", + unresolvedDomainWordsNodeId: "Domain lens word bank dropped: the node id does not resolve to a node.", nodesEmpty: "The nodes array is empty. At least one node is required.", notAllFoldersAreFixed: "If at least one direct sub-folder of root is marked as fixed, all direct sub-folders of root must be fixed.", fixedFoldersOutOfBounds: "Coordinates of fixed folders must be within a range of 0 and 100.", @@ -66,7 +67,7 @@ export function checkWarnings(file: CcFileContent): string[] { return [] } if (isCcJson2(file)) { - return collectUnresolvedEdgeWarnings(file) + return collectUnresolvedNodeIdWarnings(file) } if (fileHasHigherMinorVersion(file)) { return [`${ERROR_MESSAGES.minorApiVersionOutdated} Found: ${file.apiVersion}`] @@ -75,17 +76,21 @@ export function checkWarnings(file: CcFileContent): string[] { } /** - * cc.json 2.0 dependency edges reference nodes by id; the 2.0 reader silently drops any edge whose - * fromId/toId is absent from the file tree (mapEdges). Surface each drop as a load warning so it shows in - * the load-warnings dialog instead of only console.warn. + * cc.json 2.0 dependency edges and domain-lens word banks reference nodes by id; the 2.0 reader silently + * drops any of them whose id is absent from the file tree (mapEdges, mapDomainWords). Surface each drop as + * a load warning so it shows in the load-warnings dialog instead of only console.warn. */ -function collectUnresolvedEdgeWarnings(file: CcJson2): string[] { +function collectUnresolvedNodeIdWarnings(file: CcJson2): string[] { const root = file.files?.[0] if (root === undefined) { return [] } const nodeIds = new Set() collectFileNodeIds(root, nodeIds) + return [...collectUnresolvedEdgeWarnings(file, nodeIds), ...collectUnresolvedDomainWordsWarnings(file, nodeIds)] +} + +function collectUnresolvedEdgeWarnings(file: CcJson2, nodeIds: Set): string[] { const warnings: string[] = [] for (const edge of file.lenses.dependency?.edges ?? []) { if (!nodeIds.has(edge.fromId) || !nodeIds.has(edge.toId)) { @@ -95,6 +100,16 @@ function collectUnresolvedEdgeWarnings(file: CcJson2): string[] { return warnings } +function collectUnresolvedDomainWordsWarnings(file: CcJson2, nodeIds: Set): string[] { + const warnings: string[] = [] + for (const nodeId of Object.keys(file.lenses.domain ?? {})) { + if (!nodeIds.has(nodeId)) { + warnings.push(`${ERROR_MESSAGES.unresolvedDomainWordsNodeId} ${nodeId}`) + } + } + return warnings +} + function collectFileNodeIds(node: FileNode, into: Set) { into.add(node.id) for (const child of node.children ?? []) { diff --git a/visualization/app/codeCharta/stores/fileStore/store/filesLoaded/filesLoaded.actions.ts b/visualization/app/codeCharta/stores/fileStore/store/filesLoaded/filesLoaded.actions.ts index 1cf6bf4554..6d611d668e 100644 --- a/visualization/app/codeCharta/stores/fileStore/store/filesLoaded/filesLoaded.actions.ts +++ b/visualization/app/codeCharta/stores/fileStore/store/filesLoaded/filesLoaded.actions.ts @@ -1,5 +1,5 @@ import { createAction, props } from "@ngrx/store" -import { DependencyLensSource, MetricsLensSource, SharedView } from "../../../../model/codeCharta.model" +import { DependencyLensSource, DomainLensSource, MetricsLensSource, SharedView } from "../../../../model/codeCharta.model" import { UrlMetricSelection } from "../../../../util/queryParameter/queryParameter" export type FilesLoadedSource = "url" | "indexedDB" | "sample" | "upload" | "reset" @@ -13,6 +13,7 @@ export interface RestoredSettings { sharedView: SharedView metricsLensSource: MetricsLensSource dependencyLensSource: DependencyLensSource + domainLensSource: DomainLensSource } export interface FilesLoadedPayload { diff --git a/visualization/app/codeCharta/stores/fileStore/store/visibleFileStates.selector.ts b/visualization/app/codeCharta/stores/fileStore/store/visibleFileStates.selector.ts index 03235d493b..dd2fbcd5fc 100644 --- a/visualization/app/codeCharta/stores/fileStore/store/visibleFileStates.selector.ts +++ b/visualization/app/codeCharta/stores/fileStore/store/visibleFileStates.selector.ts @@ -1,4 +1,4 @@ -import { createSelectorFactory, defaultMemoize } from "@ngrx/store" +import { createSelector, createSelectorFactory, defaultMemoize } from "@ngrx/store" import { FileSelectionState, FileState } from "../../../model/files/files" import { getVisibleFileStates, isDeltaState } from "../../../model/files/files.helper" import { compareContentIgnoringOrder } from "../../../util/arrayHelper" @@ -57,3 +57,18 @@ function compareDeltaState(fileStates1: FileState[], fileStates2: FileState[]): export const visibleFileStatesSelector = createSelectorFactory(projection => defaultMemoize(projection, _onlyVisibleFilesMatterComparer, _onlyVisibleFilesMatterComparer) )(filesSelector, getVisibleFileStates) + +/** + * The visible file states, re-projected whenever the file store changes — same projection as + * `visibleFileStatesSelector`, but with honest default memoization. + * + * Consumers that read per-file `fileSettings` need this one. `visibleFileStatesSelector` memoizes on the + * CHECKSUMS of the visible files alone, so it reports "nothing changed" for a file set whose entries were + * replaced by richer objects and keeps handing out its previous projection. The IndexedDB restore is + * exactly that case: it first commits the files re-parsed from the persisted state — `getNameDataPair` + * round-trips through the flat 1.x export shape, which carries edges, blacklist and marked packages but + * has no slot for the domain lens, so `domainWords` comes back empty — and only then dispatches the + * persisted file states, which do carry the words, under the very same checksums. The store therefore + * ends up correct while a checksum-memoized projection stays stuck on the domain-less parse. + */ +export const visibleFileStatesWithCurrentSettingsSelector = createSelector(filesSelector, getVisibleFileStates) diff --git a/visualization/app/codeCharta/stores/rootStore/indexedDB/indexedDBWriter.spec.ts b/visualization/app/codeCharta/stores/rootStore/indexedDB/indexedDBWriter.spec.ts index 5fd925b06c..f6bfe2ea36 100644 --- a/visualization/app/codeCharta/stores/rootStore/indexedDB/indexedDBWriter.spec.ts +++ b/visualization/app/codeCharta/stores/rootStore/indexedDB/indexedDBWriter.spec.ts @@ -28,6 +28,8 @@ import { migrateCcStateRecordToV14, migrateCcStateRecordToV15, migrateCcStateRecordToV16, + migrateCcStateRecordToV17, + migrateCcStateRecordToV18, readCcState, SCENARIOS_STORE_NAME, writeCcState @@ -682,6 +684,66 @@ describe("migrateCcStateRecordToV16 (Slice 20 attributeTypes-unwrap transform)", }) }) +describe("migrateCcStateRecordToV17 (domain lens source seed transform)", () => { + it("should seed an empty domainLensSource root when the blob predates the domain lens", () => { + // Arrange + const oldShapeState = { metricsLensSource: { attributeTypes: {}, attributeDescriptors: {} } } + + // Act + const migrated = migrateCcStateRecordToV17(oldShapeState) as unknown as { domainLensSource: unknown } + + // Assert + expect(migrated.domainLensSource).toEqual({ words: {} }) + }) + + it("should leave an existing domainLensSource untouched", () => { + // Arrange + const words = { "/root": [{ text: "invoice", frequency: 3 }] } + const alreadyMigrated = { domainLensSource: { words } } + + // Act + const migrated = migrateCcStateRecordToV17(alreadyMigrated) as unknown as { domainLensSource: { words: unknown } } + + // Assert + expect(migrated.domainLensSource.words).toBe(words) + }) + + it("should pass a nullish blob through unchanged", () => { + // Arrange & Act & Assert + expect(migrateCcStateRecordToV17(null)).toBeNull() + }) +}) + +describe("migrateCcStateRecordToV18 (domain settings bar seed transform)", () => { + it("should seed the default domainBar root when the blob predates the domain settings bar", () => { + // Arrange + const oldShapeState = { metricsLensSource: { attributeTypes: {}, attributeDescriptors: {} } } + + // Act + const migrated = migrateCcStateRecordToV18(oldShapeState) as unknown as { domainBar: { topN: number } } + + // Assert + expect(migrated.domainBar.topN).toBe(150) + }) + + it("should leave an existing domainBar untouched", () => { + // Arrange + const domainBar = { topN: 25 } + const alreadyMigrated = { domainBar } + + // Act + const migrated = migrateCcStateRecordToV18(alreadyMigrated) as unknown as { domainBar: unknown } + + // Assert + expect(migrated.domainBar).toBe(domainBar) + }) + + it("should pass a nullish blob through unchanged", () => { + // Arrange & Act & Assert + expect(migrateCcStateRecordToV18(null)).toBeNull() + }) +}) + describe("openCodeChartaDB upgrade (v2 blob → chained v3 + v4 + v5 + v6 + v7 + v8 + v9 + v10 + v11 + v12 + v13 + v14 + v15 + v16 transforms)", () => { it("should re-home a persisted v2-shaped CcState blob when the DB upgrades", async () => { // Runs first (before any higher-version connection is opened) so a fresh fake-indexeddb starts at v2. diff --git a/visualization/app/codeCharta/stores/rootStore/indexedDB/indexedDBWriter.ts b/visualization/app/codeCharta/stores/rootStore/indexedDB/indexedDBWriter.ts index 3fa013c80d..529b694bb3 100644 --- a/visualization/app/codeCharta/stores/rootStore/indexedDB/indexedDBWriter.ts +++ b/visualization/app/codeCharta/stores/rootStore/indexedDB/indexedDBWriter.ts @@ -1,13 +1,15 @@ import { CcState } from "app/codeCharta/model/codeCharta.model" import { openDB } from "idb" import { defaultDependencyLensSource } from "../../dependencyLensSource/dependencyLensSource.read.facade" +import { defaultDomainBar } from "../../domainBar/domainBar.read.facade" +import { defaultDomainLensSource } from "../../domainLensSource/domainLensSource.read.facade" import { defaultMapState } from "../../mapState/mapState.read.facade" import { defaultMetricsLensSource } from "../../metricsLensSource/metricsLensSource.read.facade" import { defaultPreferences, defaultSorting } from "../../preferences/preferences.read.facade" import { defaultSharedView } from "../../sharedView/sharedView.read.facade" export const DB_NAME = "CodeCharta" -export const DB_VERSION = 16 +export const DB_VERSION = 18 export const CCSTATE_STORE_NAME = "ccstate" export const SCENARIOS_STORE_NAME = "scenarios" export const CCSTATE_PRIMARY_KEY = "id" @@ -392,6 +394,33 @@ export function migrateCcStateRecordToV16(state: T): T { return next as T } +// v17: seed the domainLensSource root (the path-keyed domain word bank) so a blob written before the +// domain lens existed still carries the root the restore path reads directly. The words are re-derived +// from the file on every load, so an empty default is correct here. +export function migrateCcStateRecordToV17(state: T): T { + if (!state || typeof state !== "object") { + return state + } + const record = state as Record + if (record["domainLensSource"]) { + return state + } + return { ...record, domainLensSource: defaultDomainLensSource } as T +} + +// v18: seed the domainBar root (the word-cloud render controls) so a blob written before the domain +// settings bar existed still carries the root the store expects. Defaults match the DLC render controls. +export function migrateCcStateRecordToV18(state: T): T { + if (!state || typeof state !== "object") { + return state + } + const record = state as Record + if (record["domainBar"]) { + return state + } + return { ...record, domainBar: defaultDomainBar } as T +} + export async function writeCcState(state: CcState) { const database = await openCodeChartaDB() // Strict durability: the default (relaxed) reports success before the data reaches disk, so a @@ -419,7 +448,7 @@ export async function deleteCcState() { // The persisted CcState record is migrated forward one version at a time: each vN transform reshapes a // (v(N-1))-shaped blob into vN. A blob written at oldVersion runs every transform whose target version it -// predates, in ascending order (a v2 blob runs v3→…→v16; a v15 blob runs only v16). +// predates, in ascending order (a v2 blob runs v3→…→v18; a v17 blob runs only v18). const CCSTATE_RECORD_MIGRATIONS: ReadonlyArray<{ version: number; migrate: (state: unknown) => unknown }> = [ { version: 3, migrate: migrateCcStateRecordToV3 }, { version: 4, migrate: migrateCcStateRecordToV4 }, @@ -434,7 +463,9 @@ const CCSTATE_RECORD_MIGRATIONS: ReadonlyArray<{ version: number; migrate: (stat { version: 13, migrate: migrateCcStateRecordToV13 }, { version: 14, migrate: migrateCcStateRecordToV14 }, { version: 15, migrate: migrateCcStateRecordToV15 }, - { version: 16, migrate: migrateCcStateRecordToV16 } + { version: 16, migrate: migrateCcStateRecordToV16 }, + { version: 17, migrate: migrateCcStateRecordToV17 }, + { version: 18, migrate: migrateCcStateRecordToV18 } ] function migrateCcStateRecord(state: unknown, oldVersion: number): unknown { @@ -456,7 +487,7 @@ export async function openCodeChartaDB() { if (!database.objectStoreNames.contains(SCENARIOS_STORE_NAME)) { database.createObjectStore(SCENARIOS_STORE_NAME, { keyPath: "id" }) } - // Migrate persisted blobs forward through all applicable transforms (v3→…→v16). + // Migrate persisted blobs forward through all applicable transforms (v3→…→v18). if (oldVersion > 0 && oldVersion < DB_VERSION) { const store = transaction.objectStore(CCSTATE_STORE_NAME) const record = await store.get(CCSTATE_STATE_ID) diff --git a/visualization/app/codeCharta/stores/rootStore/state.manager.ts b/visualization/app/codeCharta/stores/rootStore/state.manager.ts index 69d0ec7da9..562c6cd7ec 100644 --- a/visualization/app/codeCharta/stores/rootStore/state.manager.ts +++ b/visualization/app/codeCharta/stores/rootStore/state.manager.ts @@ -1,5 +1,7 @@ import { CcState } from "../../model/codeCharta.model" import { defaultDependencyLensSource } from "../dependencyLensSource/dependencyLensSource.read.facade" +import { defaultDomainBar } from "../domainBar/domainBar.read.facade" +import { defaultDomainLensSource } from "../domainLensSource/domainLensSource.read.facade" import { defaultCurrentFilesAreSampleFiles, defaultFiles, defaultIsLoadingFile } from "../fileStore/fileStore.facade" import { defaultMapState } from "../mapState/mapState.read.facade" import { defaultMetricsLensSource } from "../metricsLensSource/metricsLensSource.read.facade" @@ -9,6 +11,8 @@ import { defaultSharedView } from "../sharedView/sharedView.read.facade" export const defaultState: CcState = { metricsLensSource: defaultMetricsLensSource, dependencyLensSource: defaultDependencyLensSource, + domainLensSource: defaultDomainLensSource, + domainBar: defaultDomainBar, preferences: defaultPreferences, mapState: defaultMapState, sharedView: defaultSharedView, @@ -21,6 +25,11 @@ const objectWithDynamicKeysInStore = new Set([ "metricsLensSource.attributeTypes", "metricsLensSource.attributeDescriptors", "dependencyLensSource.attributeTypes", + // the whole path-keyed word bank is replaced wholesale, never deep-merged key-by-key + "domainLensSource.words", + // tuples: must be replaced wholesale, otherwise the deep-merge spread turns them into objects with numeric keys + "domainBar.sizeRange", + "domainBar.rotationRange", // arrays: must be replaced wholesale, otherwise the deep-merge spread turns them into objects with numeric keys "sharedView.blacklist", "sharedView.markedPackages", diff --git a/visualization/app/codeCharta/stores/rootStore/store.ts b/visualization/app/codeCharta/stores/rootStore/store.ts index bc4daca1ac..876a6e71d5 100644 --- a/visualization/app/codeCharta/stores/rootStore/store.ts +++ b/visualization/app/codeCharta/stores/rootStore/store.ts @@ -1,6 +1,8 @@ import { ActionReducer } from "@ngrx/store" import { CcState } from "../../model/codeCharta.model" import { dependencyLensSource } from "../dependencyLensSource/dependencyLensSource.read.facade" +import { domainBar } from "../domainBar/domainBar.read.facade" +import { domainLensSource } from "../domainLensSource/domainLensSource.read.facade" import { currentFilesAreSampleFiles, files, isLoadingFile } from "../fileStore/fileStore.facade" import { mapState } from "../mapState/mapState.read.facade" import { metricsLensSource } from "../metricsLensSource/metricsLensSource.read.facade" @@ -12,6 +14,8 @@ import { _applyPartialState } from "./state.manager" export const appReducers = { metricsLensSource, dependencyLensSource, + domainLensSource, + domainBar, preferences, mapState, sharedView, diff --git a/visualization/app/codeCharta/util/__snapshots__/aggregationGenerator.spec.ts.snap b/visualization/app/codeCharta/util/__snapshots__/aggregationGenerator.spec.ts.snap index 04fdc06d35..4cecac4ed4 100644 --- a/visualization/app/codeCharta/util/__snapshots__/aggregationGenerator.spec.ts.snap +++ b/visualization/app/codeCharta/util/__snapshots__/aggregationGenerator.spec.ts.snap @@ -212,6 +212,7 @@ exports[`AggregationGenerator aggregation of four maps 1`] = ` "nodes": {}, }, "blacklist": [], + "domainWords": {}, "edges": [], "markedPackages": [], }, @@ -340,6 +341,7 @@ exports[`AggregationGenerator aggregation of two maps 1`] = ` "nodes": {}, }, "blacklist": [], + "domainWords": {}, "edges": [], "markedPackages": [], }, diff --git a/visualization/app/codeCharta/util/aggregationGenerator.ts b/visualization/app/codeCharta/util/aggregationGenerator.ts index d62b049566..0002b84903 100644 --- a/visualization/app/codeCharta/util/aggregationGenerator.ts +++ b/visualization/app/codeCharta/util/aggregationGenerator.ts @@ -51,7 +51,8 @@ export class AggregationGenerator { blacklist: [], attributeTypes: { nodes: {}, edges: {} }, attributeDescriptors: {}, - markedPackages: [] + markedPackages: [], + domainWords: {} } } } diff --git a/visualization/app/codeCharta/util/ccJson2Schema.json b/visualization/app/codeCharta/util/ccJson2Schema.json index 3fc5e01c4e..7992ff359f 100644 --- a/visualization/app/codeCharta/util/ccJson2Schema.json +++ b/visualization/app/codeCharta/util/ccJson2Schema.json @@ -58,13 +58,13 @@ "type": "string" }, "Lenses": { - "description": "Additive overlays joined to `files` by id. `metrics`, `dependency` and `clusters` are concrete; `domain`/`security` are reserved; any unknown top-level lens is preserved verbatim on round-trip (hence no additionalProperties:false here).", + "description": "Additive overlays joined to `files` by id. `metrics`, `dependency`, `clusters` and `domain` are concrete; `security` is reserved; any unknown top-level lens is preserved verbatim on round-trip (hence no additionalProperties:false here).", "type": "object", "properties": { "metrics": { "$ref": "#/definitions/MetricsLens" }, "dependency": { "$ref": "#/definitions/DependencyLens" }, "clusters": { "$ref": "#/definitions/ClustersLens" }, - "domain": { "type": "object" }, + "domain": { "$ref": "#/definitions/DomainLens" }, "security": { "type": "object" } } }, @@ -193,6 +193,25 @@ "weight": { "type": "number", "minimum": 0, "maximum": 1 } } }, + "DomainLens": { + "description": "Per-node domain vocabulary, keyed by node id: the word bank the domain bar and word cloud render.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "$ref": "#/definitions/DomainWord" } + } + }, + "DomainWord": { + "description": "One extracted domain term for a node. `frequency` is the raw occurrence count; `tfidf` is the optional corpus-relative weight.", + "type": "object", + "additionalProperties": false, + "required": ["text", "frequency"], + "properties": { + "text": { "type": "string" }, + "frequency": { "type": "number" }, + "tfidf": { "type": "number" } + } + }, "AttributeDescriptor": { "description": "Human-facing metadata for a metric/edge attribute.", "type": "object", diff --git a/visualization/app/codeCharta/util/deltaGenerator.ts b/visualization/app/codeCharta/util/deltaGenerator.ts index 17f0d27a79..f8672df55b 100644 --- a/visualization/app/codeCharta/util/deltaGenerator.ts +++ b/visualization/app/codeCharta/util/deltaGenerator.ts @@ -154,7 +154,8 @@ export class DeltaGenerator { blacklist: [], attributeTypes: { nodes: {}, edges: {} }, attributeDescriptors: {}, - markedPackages: [] + markedPackages: [], + domainWords: {} } } } diff --git a/visualization/public/codeCharta/assets/sample1.cc.json b/visualization/public/codeCharta/assets/sample1.cc.json index 37f5bcb262..e6cc9baa3e 100755 --- a/visualization/public/codeCharta/assets/sample1.cc.json +++ b/visualization/public/codeCharta/assets/sample1.cc.json @@ -114,6 +114,42 @@ "avgCommits": "absolute" }, "attributeDescriptors": {} + }, + "domain": { + "164ddff4bb1345e1": [ + { "text": "invoice", "frequency": 42, "tfidf": 0.81 }, + { "text": "payment", "frequency": 30, "tfidf": 0.64 }, + { "text": "customer", "frequency": 25, "tfidf": 0.55 }, + { "text": "account", "frequency": 20, "tfidf": 0.52 }, + { "text": "order", "frequency": 18, "tfidf": 0.41 }, + { "text": "ledger", "frequency": 16, "tfidf": 0.44 }, + { "text": "balance", "frequency": 14, "tfidf": 0.37 }, + { "text": "shipment", "frequency": 12, "tfidf": 0.28 }, + { "text": "transaction", "frequency": 9, "tfidf": 0.22 } + ], + "1ae98c1a93690d75": [ + { "text": "account", "frequency": 20, "tfidf": 0.52 }, + { "text": "balance", "frequency": 14, "tfidf": 0.37 } + ], + "7de6343f370ff7cf": [ + { "text": "ledger", "frequency": 16, "tfidf": 0.44 }, + { "text": "transaction", "frequency": 9, "tfidf": 0.22 } + ], + "3d6521b0884ce9f4": [ + { "text": "invoice", "frequency": 42, "tfidf": 0.81 }, + { "text": "payment", "frequency": 30, "tfidf": 0.64 }, + { "text": "customer", "frequency": 25, "tfidf": 0.55 }, + { "text": "order", "frequency": 18, "tfidf": 0.41 }, + { "text": "shipment", "frequency": 12, "tfidf": 0.28 } + ], + "ea70504d5daa3547": [ + { "text": "account", "frequency": 12, "tfidf": 0.52 }, + { "text": "balance", "frequency": 5, "tfidf": 0.37 } + ], + "120a1569e3556450": [ + { "text": "balance", "frequency": 9, "tfidf": 0.37 }, + { "text": "account", "frequency": 8, "tfidf": 0.52 } + ] } } } From 827c9ec28750ff351def140df812854fb0dabca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20H=C3=BChn?= Date: Mon, 20 Jul 2026 13:53:13 +0200 Subject: [PATCH 05/52] refactor(visualization): extract reusable hover tooltip service Lift the positioning and show/hide logic out of the codeMap tooltip service into a standalone hoverTooltip service so a non-3D view can reuse it. Behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../codeMap.tooltip.service.spec.ts | 6 +- .../threeViewer/codeMap.tooltip.service.ts | 125 +++-------------- .../codeCharta/util/hoverTooltip.service.ts | 132 ++++++++++++++++++ 3 files changed, 160 insertions(+), 103 deletions(-) create mode 100644 visualization/app/codeCharta/util/hoverTooltip.service.ts diff --git a/visualization/app/codeCharta/renderer/threeViewer/codeMap.tooltip.service.spec.ts b/visualization/app/codeCharta/renderer/threeViewer/codeMap.tooltip.service.spec.ts index 5dc7004f9f..af3495b282 100644 --- a/visualization/app/codeCharta/renderer/threeViewer/codeMap.tooltip.service.spec.ts +++ b/visualization/app/codeCharta/renderer/threeViewer/codeMap.tooltip.service.spec.ts @@ -1,3 +1,4 @@ +import { TestBed } from "@angular/core/testing" import { Node } from "../../model/codeCharta.model" import { CodeMapTooltipService } from "./codeMap.tooltip.service" import { CodeMapTooltipStore } from "./stores/codeMapTooltip.store" @@ -10,7 +11,10 @@ describe("CodeMapTooltipService", () => { const codeMapTooltipStore = { getSelectedMetrics: () => ({ areaMetric: "rloc", heightMetric: "mcc", colorMetric: "coverage" }) } as unknown as CodeMapTooltipStore - tooltipService = new CodeMapTooltipService(codeMapTooltipStore) + TestBed.configureTestingModule({ + providers: [{ provide: CodeMapTooltipStore, useValue: codeMapTooltipStore }] + }) + tooltipService = TestBed.inject(CodeMapTooltipService) sampleNode = { name: "sample.ts", diff --git a/visualization/app/codeCharta/renderer/threeViewer/codeMap.tooltip.service.ts b/visualization/app/codeCharta/renderer/threeViewer/codeMap.tooltip.service.ts index 8504bf65f6..10a956d094 100644 --- a/visualization/app/codeCharta/renderer/threeViewer/codeMap.tooltip.service.ts +++ b/visualization/app/codeCharta/renderer/threeViewer/codeMap.tooltip.service.ts @@ -1,5 +1,6 @@ -import { Injectable } from "@angular/core" +import { Injectable, inject } from "@angular/core" import { KeyValuePair } from "../../model/codeCharta.model" +import { HoverTooltipContent, HoverTooltipService } from "../../util/hoverTooltip.service" import { CodeMapTooltipStore } from "./stores/codeMapTooltip.store" export interface TooltipNode { @@ -8,66 +9,36 @@ export interface TooltipNode { attributes?: KeyValuePair } +const MISSING_VALUE = "—" + +/** + * The 3D map's hover tooltip: the area/height/color metrics of the hovered building. Only the CONTENT + * is map-specific — the floating element itself is the shared HoverTooltipService, so other views can + * show their own content in the same tooltip without inheriting metrics semantics. + */ @Injectable({ providedIn: "root" }) export class CodeMapTooltipService { - private static readonly CURSOR_OFFSET_X = 12 - private static readonly CURSOR_OFFSET_Y = 12 - private static readonly VIEWPORT_PADDING = 8 - private static readonly TOOLTIP_STYLE = ` - position: fixed; - z-index: 1000; - background: rgba(255, 255, 255, 0.97); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - border-radius: 6px; - padding: 6px 10px; - font-family: Roboto, 'Helvetica Neue', sans-serif; - font-size: 12px; - line-height: 1.4; - color: #1a1a1a; - white-space: nowrap; - pointer-events: none; - user-select: none; - border: 1px solid #000; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); - opacity: 0; - transition: opacity 0.1s ease-out; - ` - - private tooltipElement: HTMLDivElement | null = null - private visible = false + private readonly hoverTooltipService = inject(HoverTooltipService) private currentNodeId: number | null = null constructor(private readonly codeMapTooltipStore: CodeMapTooltipStore) {} show(node: TooltipNode, clientX: number, clientY: number) { - if (!this.tooltipElement) { - this.createTooltipElement() - } - - this.populateTooltip(node) - this.positionTooltip(clientX, clientY) - this.tooltipElement.style.opacity = "1" - this.visible = true + this.hoverTooltipService.show(this.buildContent(node), clientX, clientY) this.currentNodeId = node.id ?? null } updatePosition(clientX: number, clientY: number) { - if (this.visible && this.tooltipElement) { - this.positionTooltip(clientX, clientY) - } + this.hoverTooltipService.updatePosition(clientX, clientY) } hide() { - if (this.tooltipElement) { - this.tooltipElement.style.opacity = "0" - } - this.visible = false + this.hoverTooltipService.hide() this.currentNodeId = null } isVisible(): boolean { - return this.visible + return this.hoverTooltipService.isVisible() } getCurrentNodeId(): number | null { @@ -75,73 +46,23 @@ export class CodeMapTooltipService { } getRect(): DOMRect | null { - if (!this.visible || !this.tooltipElement) { - return null - } - return this.tooltipElement.getBoundingClientRect() + return this.hoverTooltipService.getRect() } dispose() { - this.tooltipElement?.remove() - this.tooltipElement = null - this.visible = false + this.hoverTooltipService.dispose() this.currentNodeId = null } - private createTooltipElement() { - this.tooltipElement = document.createElement("div") - this.tooltipElement.id = "cc-hover-tooltip" - this.tooltipElement.style.cssText = CodeMapTooltipService.TOOLTIP_STYLE - document.body.appendChild(this.tooltipElement) - } - - private populateTooltip(node: TooltipNode) { + private buildContent(node: TooltipNode): HoverTooltipContent { const { areaMetric, heightMetric, colorMetric } = this.codeMapTooltipStore.getSelectedMetrics() - const metrics = [ - { label: areaMetric, value: node.attributes?.[areaMetric] }, - { label: heightMetric, value: node.attributes?.[heightMetric] }, - { label: colorMetric, value: node.attributes?.[colorMetric] } - ] - - this.tooltipElement.textContent = "" - - const nameDiv = document.createElement("div") - nameDiv.style.cssText = "font-weight: 500; margin-bottom: 2px;" - nameDiv.textContent = node.name - this.tooltipElement.append(nameDiv) - - for (const metric of metrics) { - const metricDiv = document.createElement("div") - metricDiv.style.cssText = "font-size: 10px; color: #666;" - metricDiv.textContent = `${metric.label}: ${metric.value ?? "\u2014"}` - this.tooltipElement.append(metricDiv) - } - } - - private positionTooltip(clientX: number, clientY: number) { - let x = clientX + CodeMapTooltipService.CURSOR_OFFSET_X - let y = clientY + CodeMapTooltipService.CURSOR_OFFSET_Y - - const rect = this.tooltipElement.getBoundingClientRect() - const viewportWidth = window.innerWidth - const viewportHeight = window.innerHeight - - if (x + rect.width > viewportWidth - CodeMapTooltipService.VIEWPORT_PADDING) { - x = clientX - rect.width - CodeMapTooltipService.CURSOR_OFFSET_X + return { + title: node.name, + rows: [areaMetric, heightMetric, colorMetric].map(metric => ({ + label: metric, + value: `${node.attributes?.[metric] ?? MISSING_VALUE}` + })) } - if (y + rect.height > viewportHeight - CodeMapTooltipService.VIEWPORT_PADDING) { - y = clientY - rect.height - CodeMapTooltipService.CURSOR_OFFSET_Y - } - - if (x < CodeMapTooltipService.VIEWPORT_PADDING) { - x = CodeMapTooltipService.VIEWPORT_PADDING - } - if (y < CodeMapTooltipService.VIEWPORT_PADDING) { - y = CodeMapTooltipService.VIEWPORT_PADDING - } - - this.tooltipElement.style.left = `${x}px` - this.tooltipElement.style.top = `${y}px` } } diff --git a/visualization/app/codeCharta/util/hoverTooltip.service.ts b/visualization/app/codeCharta/util/hoverTooltip.service.ts new file mode 100644 index 0000000000..44249676fb --- /dev/null +++ b/visualization/app/codeCharta/util/hoverTooltip.service.ts @@ -0,0 +1,132 @@ +import { Injectable } from "@angular/core" + +export interface HoverTooltipRow { + label: string + value: string +} + +/** A title line plus any number of muted label/value rows. */ +export interface HoverTooltipContent { + title: string + rows: HoverTooltipRow[] +} + +const CURSOR_OFFSET_X = 12 +const CURSOR_OFFSET_Y = 12 +const VIEWPORT_PADDING = 8 + +// Themed via the daisyUI custom properties rather than literal colours, so the tooltip follows the +// active theme. It is appended to document.body (it must escape the explorer's overflow clipping), +// which puts it outside every component's style scope — hence inline styles rather than a class. +const TOOLTIP_STYLE = ` + position: fixed; + z-index: 1000; + background: color-mix(in srgb, var(--color-base-100) 97%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border-radius: 6px; + padding: 6px 10px; + font-family: Roboto, 'Helvetica Neue', sans-serif; + font-size: 12px; + line-height: 1.4; + color: var(--color-base-content); + white-space: nowrap; + pointer-events: none; + user-select: none; + border: 1px solid var(--color-base-300); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); + opacity: 0; + transition: opacity 0.1s ease-out; +` + +/** + * The single floating hover tooltip, shared by everything that needs one (the 3D map's buildings and + * the explorer's rows). It renders whatever title/rows it is handed and knows nothing about metrics, + * domain words or any other content — callers compose the content. + */ +@Injectable({ providedIn: "root" }) +export class HoverTooltipService { + private tooltipElement: HTMLDivElement | null = null + private visible = false + + show(content: HoverTooltipContent, clientX: number, clientY: number) { + if (!this.tooltipElement) { + this.createTooltipElement() + } + + this.populate(content) + this.position(clientX, clientY) + this.tooltipElement.style.opacity = "1" + this.visible = true + } + + updatePosition(clientX: number, clientY: number) { + if (this.visible && this.tooltipElement) { + this.position(clientX, clientY) + } + } + + hide() { + if (this.tooltipElement) { + this.tooltipElement.style.opacity = "0" + } + this.visible = false + } + + isVisible(): boolean { + return this.visible + } + + getRect(): DOMRect | null { + if (!this.visible || !this.tooltipElement) { + return null + } + return this.tooltipElement.getBoundingClientRect() + } + + dispose() { + this.tooltipElement?.remove() + this.tooltipElement = null + this.visible = false + } + + private createTooltipElement() { + this.tooltipElement = document.createElement("div") + this.tooltipElement.id = "cc-hover-tooltip" + this.tooltipElement.style.cssText = TOOLTIP_STYLE + document.body.appendChild(this.tooltipElement) + } + + private populate(content: HoverTooltipContent) { + this.tooltipElement.textContent = "" + + const titleElement = document.createElement("div") + titleElement.style.cssText = "font-weight: 600; margin-bottom: 2px;" + titleElement.textContent = content.title + this.tooltipElement.append(titleElement) + + for (const row of content.rows) { + const rowElement = document.createElement("div") + rowElement.style.cssText = "font-size: 10px; opacity: 0.7;" + rowElement.textContent = `${row.label}: ${row.value}` + this.tooltipElement.append(rowElement) + } + } + + private position(clientX: number, clientY: number) { + let x = clientX + CURSOR_OFFSET_X + let y = clientY + CURSOR_OFFSET_Y + + const rect = this.tooltipElement.getBoundingClientRect() + + if (x + rect.width > window.innerWidth - VIEWPORT_PADDING) { + x = clientX - rect.width - CURSOR_OFFSET_X + } + if (y + rect.height > window.innerHeight - VIEWPORT_PADDING) { + y = clientY - rect.height - CURSOR_OFFSET_Y + } + + this.tooltipElement.style.left = `${Math.max(x, VIEWPORT_PADDING)}px` + this.tooltipElement.style.top = `${Math.max(y, VIEWPORT_PADDING)}px` + } +} From 3cb62ba7d88d4873ed6ff8aaaa45d3039e13b5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20H=C3=BChn?= Date: Mon, 20 Jul 2026 13:53:31 +0200 Subject: [PATCH 06/52] feat(visualization): add domain view with word cloud Split the single page into routed metrics and domain views, with a nav bar view switcher and a keep-alive route reuse strategy so switching back does not rebuild the map. Routing uses hash location and preserves query params across switches. The domain view renders the domain lens as an echarts word cloud with a domainBar for shape, rotation and sizing settings, built on the shared bar UI kit. The sidebar explorer is generalized behind an explorerHost so both views drive it, and the loading spinner becomes per-view via a viewReadiness store. Adds a lint:styles check enforcing Tailwind utilities over component style files, wired into the visualization CI workflow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test_visualization.yml | 6 + visualization/.dependency-cruiser.js | 82 +++-- visualization/CHANGELOG.md | 18 ++ visualization/app/app.config.spec.ts | 39 +++ visualization/app/app.config.ts | 43 +++ .../app/codeCharta/e2e/domainView.e2e.ts | 147 +++++++++ .../app/codeCharta/e2e/loadPipeline.e2e.ts | 6 +- .../codeCharta/e2e/viewLoadingSpinner.e2e.ts | 71 +++++ .../bottomBar/bottomBar.component.html | 2 +- .../bottomBar/bottomBar.component.ts | 5 +- .../hoveredPath/hoveredPath.component.html | 6 +- .../hoveredPath/hoveredPath.component.spec.ts | 75 ++++- .../hoveredPath/hoveredPath.component.ts | 20 +- .../hoveredNodePathPanelData.selector.spec.ts | 40 ++- .../hoveredNodePathPanelData.selector.ts | 13 +- .../bottomBar/stores/hoveredPath.store.ts | 3 +- .../features/codeMap/codeMap.component.ts | 22 -- .../renderCodeMap.effect.spec.ts | 51 ++- .../renderCodeMap.effect.ts | 24 +- .../setLoadingIndicator.effect.spec.ts | 152 ++++----- .../setLoadingIndicator.effect.ts | 147 ++++----- .../domainBar/domainBar.component.html | 3 + .../domainBar/domainBar.component.spec.ts | 255 +++++++++++++++ .../domainBar/domainBar.component.ts | 25 ++ .../rotationSegment.component.html | 11 + .../rotationSegment.component.ts | 26 ++ .../rotationSettingsPopover.component.html | 48 +++ .../rotationSettingsPopover.component.ts | 35 ++ .../shapeSegment/shapeSegment.component.html | 11 + .../shapeSegment/shapeSegment.component.ts | 21 ++ .../shapeSettingsPopover.component.html | 17 + .../shapeSettingsPopover.component.ts | 29 ++ .../wordSizingSegment.component.html | 12 + .../wordSizingSegment.component.ts | 28 ++ .../wordSizingSettingsPopover.component.html | 102 ++++++ .../wordSizingSettingsPopover.component.ts | 63 ++++ .../features/domainBar/domainBar.po.ts | 56 ++++ .../codeCharta/features/domainBar/facade.ts | 2 + .../stores/domainBar.read.store.spec.ts | 42 +++ .../domainBar/stores/domainBar.read.store.ts | 19 ++ .../stores/domainBar.write.store.spec.ts | 45 +++ .../domainBar/stores/domainBar.write.store.ts | 54 ++++ .../modeToggle/modeToggle.component.html | 9 +- .../modeToggle/modeToggle.component.spec.ts | 12 +- .../components/navBar/navBar.component.html | 6 + .../navBar/navBar.component.spec.ts | 48 +-- .../components/navBar/navBar.component.ts | 33 +- .../viewSwitcher/viewSwitcher.component.html | 33 ++ .../viewSwitcher.component.spec.ts | 96 ++++++ .../viewSwitcher/viewSwitcher.component.ts | 29 ++ .../viewSwitcher/viewSwitcher.po.ts | 26 ++ .../features/navBar/effects/navBar.effects.ts | 4 + .../redirectAwayFromDomainView.effect.spec.ts | 201 ++++++++++++ .../redirectAwayFromDomainView.effect.ts | 64 ++++ .../navBar/stores/viewSwitcher.read.store.ts | 12 + .../features/scenarios/e2e/scenarios.e2e.ts | 19 +- .../features/scenarios/e2e/scenarios.po.ts | 21 +- .../loadingFileProgressSpinner.component.html | 4 +- .../loadingFileProgressSpinner.component.ts | 20 +- ...loadingFileProgressSpinner.service.spec.ts | 90 ++++++ .../loadingFileProgressSpinner.service.ts | 33 +- .../explorerHeader.component.html | 32 +- .../explorerHeader.component.spec.ts | 24 +- .../explorerHeader.component.ts | 4 + .../explorerSortControl.component.html | 55 ++-- .../explorerSortControl.component.spec.ts | 55 ++-- .../explorerSortControl.component.ts | 20 +- .../explorerTree.component.spec.ts | 12 +- .../explorerTreeItemIcon.component.ts | 9 +- .../explorerTreeItemName.component.html | 4 +- .../explorerTreeItemName.component.spec.ts | 18 +- .../explorerTreeItemName.component.ts | 10 +- .../explorerTreeLevel.component.html | 17 +- .../explorerTreeLevel.component.spec.ts | 188 ++++++----- .../explorerTreeLevel.component.ts | 77 ++--- .../explorerTreeLevel.e2e.ts | 2 +- .../explorerTreeLevel/explorerTreeLevel.po.ts | 12 +- .../sidebarExplorer.component.html | 35 +- .../sidebarExplorer.component.spec.ts | 175 +++++++++- .../sidebarExplorer.component.ts | 51 ++- ...revealSelectedNodeAfterLoad.effect.spec.ts | 82 +++++ .../revealSelectedNodeAfterLoad.effect.ts | 43 +++ .../effects/sidebarExplorer.effects.ts | 3 +- .../sidebarExplorer/explorerHost.mocks.ts | 25 ++ .../features/sidebarExplorer/explorerHost.ts | 55 ++++ .../features/sidebarExplorer/facade.ts | 5 + .../sidebarExplorer/scrollRowIntoView.ts | 30 ++ .../services/explorerCollapse.service.spec.ts | 13 + .../services/explorerCollapse.service.ts | 10 +- .../services/explorerReveal.service.ts | 15 +- .../services/explorerWidth.service.ts | 19 +- .../stores/sidebarExplorer.read.store.ts | 3 +- .../stores/sidebarExplorer.write.store.ts | 12 +- .../sidebarInspector/sidebarInspector.e2e.ts | 4 +- .../sidebarInspector/sidebarInspector.po.ts | 11 +- .../wordCloud/wordCloud.component.html | 29 ++ .../wordCloud/wordCloud.component.spec.ts | 263 +++++++++++++++ .../wordCloud/wordCloud.component.ts | 194 ++++++++++++ .../codeCharta/features/wordCloud/facade.ts | 1 + .../stores/wordCloud.read.store.spec.ts | 48 +++ .../wordCloud/stores/wordCloud.read.store.ts | 35 ++ .../wordCloud/stores/wordCloud.write.store.ts | 14 + .../wordCloud/util/color.util.spec.ts | 75 +++++ .../features/wordCloud/util/color.util.ts | 66 ++++ .../util/wordCloudOption.builder.spec.ts | 299 ++++++++++++++++++ .../wordCloud/util/wordCloudOption.builder.ts | 196 ++++++++++++ .../codeCharta/load/loadFiles.useCase.spec.ts | 7 + .../app/codeCharta/load/loadFiles.useCase.ts | 15 +- .../load/loadInitialFile.store.spec.ts | 182 +++++++++++ .../codeCharta/load/loadInitialFile.store.ts | 82 ++++- .../routing/activeView.store.spec.ts | 69 ++++ .../codeCharta/routing/activeView.store.ts | 32 ++ .../keepAliveRouteReuse.strategy.spec.ts | 59 ++++ .../routing/keepAliveRouteReuse.strategy.ts | 48 +++ ...queryParamPreservationOnViewSwitch.spec.ts | 133 ++++++++ ...eryPreservingHashLocation.strategy.spec.ts | 108 +++++++ .../queryPreservingHashLocation.strategy.ts | 55 ++++ .../app/codeCharta/routing/routePaths.ts | 21 ++ .../viewReadiness/viewReadiness.store.spec.ts | 64 ++++ .../viewReadiness/viewReadiness.store.ts | 60 ++++ .../queryParams.service.spec.ts | 45 +++ .../queryParameter/queryParams.service.ts | 8 +- .../views/codeCharta.component.html | 14 +- .../codeCharta/views/codeCharta.component.ts | 31 +- .../domainView/domainView.component.html | 20 ++ .../domainView/domainView.component.spec.ts | 100 ++++++ .../views/domainView/domainView.component.ts | 42 +++ .../explorerHost/domainExplorerHost.spec.ts | 139 ++++++++ .../explorerHost/domainExplorerHost.ts | 95 ++++++ .../explorerHost/metricsExplorerHost.spec.ts | 151 +++++++++ .../explorerHost/metricsExplorerHost.ts | 97 ++++++ .../metricsView/metricsView.component.html | 14 + .../metricsView/metricsView.component.ts | 35 ++ visualization/app/tailwind.css | 8 + visualization/conf/jestUnit.config.json | 5 +- visualization/mocks/echartsMock.js | 13 + visualization/mocks/echartsWordcloudMock.js | 3 + visualization/package-lock.json | 37 +++ visualization/package.json | 5 +- visualization/script/lintComponentStyles.js | 40 +++ 140 files changed, 6156 insertions(+), 667 deletions(-) create mode 100644 visualization/app/app.config.spec.ts create mode 100644 visualization/app/codeCharta/e2e/domainView.e2e.ts create mode 100644 visualization/app/codeCharta/e2e/viewLoadingSpinner.e2e.ts create mode 100644 visualization/app/codeCharta/features/domainBar/components/domainBar/domainBar.component.html create mode 100644 visualization/app/codeCharta/features/domainBar/components/domainBar/domainBar.component.spec.ts create mode 100644 visualization/app/codeCharta/features/domainBar/components/domainBar/domainBar.component.ts create mode 100644 visualization/app/codeCharta/features/domainBar/components/rotationSegment/rotationSegment.component.html create mode 100644 visualization/app/codeCharta/features/domainBar/components/rotationSegment/rotationSegment.component.ts create mode 100644 visualization/app/codeCharta/features/domainBar/components/rotationSettingsPopover/rotationSettingsPopover.component.html create mode 100644 visualization/app/codeCharta/features/domainBar/components/rotationSettingsPopover/rotationSettingsPopover.component.ts create mode 100644 visualization/app/codeCharta/features/domainBar/components/shapeSegment/shapeSegment.component.html create mode 100644 visualization/app/codeCharta/features/domainBar/components/shapeSegment/shapeSegment.component.ts create mode 100644 visualization/app/codeCharta/features/domainBar/components/shapeSettingsPopover/shapeSettingsPopover.component.html create mode 100644 visualization/app/codeCharta/features/domainBar/components/shapeSettingsPopover/shapeSettingsPopover.component.ts create mode 100644 visualization/app/codeCharta/features/domainBar/components/wordSizingSegment/wordSizingSegment.component.html create mode 100644 visualization/app/codeCharta/features/domainBar/components/wordSizingSegment/wordSizingSegment.component.ts create mode 100644 visualization/app/codeCharta/features/domainBar/components/wordSizingSettingsPopover/wordSizingSettingsPopover.component.html create mode 100644 visualization/app/codeCharta/features/domainBar/components/wordSizingSettingsPopover/wordSizingSettingsPopover.component.ts create mode 100644 visualization/app/codeCharta/features/domainBar/domainBar.po.ts create mode 100644 visualization/app/codeCharta/features/domainBar/facade.ts create mode 100644 visualization/app/codeCharta/features/domainBar/stores/domainBar.read.store.spec.ts create mode 100644 visualization/app/codeCharta/features/domainBar/stores/domainBar.read.store.ts create mode 100644 visualization/app/codeCharta/features/domainBar/stores/domainBar.write.store.spec.ts create mode 100644 visualization/app/codeCharta/features/domainBar/stores/domainBar.write.store.ts create mode 100644 visualization/app/codeCharta/features/navBar/components/viewSwitcher/viewSwitcher.component.html create mode 100644 visualization/app/codeCharta/features/navBar/components/viewSwitcher/viewSwitcher.component.spec.ts create mode 100644 visualization/app/codeCharta/features/navBar/components/viewSwitcher/viewSwitcher.component.ts create mode 100644 visualization/app/codeCharta/features/navBar/components/viewSwitcher/viewSwitcher.po.ts create mode 100644 visualization/app/codeCharta/features/navBar/effects/navBar.effects.ts create mode 100644 visualization/app/codeCharta/features/navBar/effects/redirectAwayFromDomainView/redirectAwayFromDomainView.effect.spec.ts create mode 100644 visualization/app/codeCharta/features/navBar/effects/redirectAwayFromDomainView/redirectAwayFromDomainView.effect.ts create mode 100644 visualization/app/codeCharta/features/navBar/stores/viewSwitcher.read.store.ts create mode 100644 visualization/app/codeCharta/features/shared/services/loadingFileProgressSpinner.service.spec.ts create mode 100644 visualization/app/codeCharta/features/sidebarExplorer/effects/revealSelectedNodeAfterLoad/revealSelectedNodeAfterLoad.effect.spec.ts create mode 100644 visualization/app/codeCharta/features/sidebarExplorer/effects/revealSelectedNodeAfterLoad/revealSelectedNodeAfterLoad.effect.ts create mode 100644 visualization/app/codeCharta/features/sidebarExplorer/explorerHost.mocks.ts create mode 100644 visualization/app/codeCharta/features/sidebarExplorer/explorerHost.ts create mode 100644 visualization/app/codeCharta/features/sidebarExplorer/scrollRowIntoView.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/components/wordCloud/wordCloud.component.html create mode 100644 visualization/app/codeCharta/features/wordCloud/components/wordCloud/wordCloud.component.spec.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/components/wordCloud/wordCloud.component.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/facade.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/stores/wordCloud.read.store.spec.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/stores/wordCloud.read.store.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/stores/wordCloud.write.store.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/util/color.util.spec.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/util/color.util.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/util/wordCloudOption.builder.spec.ts create mode 100644 visualization/app/codeCharta/features/wordCloud/util/wordCloudOption.builder.ts create mode 100644 visualization/app/codeCharta/load/loadInitialFile.store.spec.ts create mode 100644 visualization/app/codeCharta/routing/activeView.store.spec.ts create mode 100644 visualization/app/codeCharta/routing/activeView.store.ts create mode 100644 visualization/app/codeCharta/routing/keepAliveRouteReuse.strategy.spec.ts create mode 100644 visualization/app/codeCharta/routing/keepAliveRouteReuse.strategy.ts create mode 100644 visualization/app/codeCharta/routing/queryParamPreservationOnViewSwitch.spec.ts create mode 100644 visualization/app/codeCharta/routing/queryPreservingHashLocation.strategy.spec.ts create mode 100644 visualization/app/codeCharta/routing/queryPreservingHashLocation.strategy.ts create mode 100644 visualization/app/codeCharta/routing/routePaths.ts create mode 100644 visualization/app/codeCharta/stores/viewReadiness/viewReadiness.store.spec.ts create mode 100644 visualization/app/codeCharta/stores/viewReadiness/viewReadiness.store.ts create mode 100644 visualization/app/codeCharta/views/domainView/domainView.component.html create mode 100644 visualization/app/codeCharta/views/domainView/domainView.component.spec.ts create mode 100644 visualization/app/codeCharta/views/domainView/domainView.component.ts create mode 100644 visualization/app/codeCharta/views/domainView/explorerHost/domainExplorerHost.spec.ts create mode 100644 visualization/app/codeCharta/views/domainView/explorerHost/domainExplorerHost.ts create mode 100644 visualization/app/codeCharta/views/metricsView/explorerHost/metricsExplorerHost.spec.ts create mode 100644 visualization/app/codeCharta/views/metricsView/explorerHost/metricsExplorerHost.ts create mode 100644 visualization/app/codeCharta/views/metricsView/metricsView.component.html create mode 100644 visualization/app/codeCharta/views/metricsView/metricsView.component.ts create mode 100644 visualization/mocks/echartsMock.js create mode 100644 visualization/mocks/echartsWordcloudMock.js create mode 100644 visualization/script/lintComponentStyles.js diff --git a/.github/workflows/test_visualization.yml b/.github/workflows/test_visualization.yml index 0f8ce6f7e6..6012f1659c 100644 --- a/.github/workflows/test_visualization.yml +++ b/.github/workflows/test_visualization.yml @@ -55,6 +55,12 @@ jobs: npm run lint:architecture || (echo "❌ Architecture violations detected! Check the output above for details." && exit 1) working-directory: ${{env.working-directory}} + - name: Check component styles + run: | + echo "Checking for component style files (no-component-scss-files)..." + npm run lint:styles || (echo "❌ Component style files detected! Use Tailwind utility classes instead." && exit 1) + working-directory: ${{env.working-directory}} + - name: Check dead code run: | echo "Checking for unused exports/files (knip)..." diff --git a/visualization/.dependency-cruiser.js b/visualization/.dependency-cruiser.js index eb47835923..488623dc40 100644 --- a/visualization/.dependency-cruiser.js +++ b/visualization/.dependency-cruiser.js @@ -185,28 +185,40 @@ module.exports = { name: "stores-own-ccjson-source", severity: "error", comment: - "The cc.json SOURCE state now lives in stores/ (Slice 19b moved it OUT of the lenses so the ngrx composition root store/ no longer imports lenses/): the metricsLensSource home (node attributeTypes + attributeDescriptors + the metricsLensSource root reducer/selector) and the dependencyLensSource home (edge attributeTypes + the dependencyLensSource root). Its ngrx store/ internals are reached from outside the owning home ONLY through that home's read/write facade — so the composition root (store/), the load pipeline (load/) and the now-pure-projection lenses all go through the facades, never store/ internals. Replaces the old lens-owns-ccjson-source rule (the source is no longer lens-owned). Spec/e2e/mocks exempt.", + "The cc.json SOURCE state now lives in stores/ (Slice 19b moved it OUT of the lenses so the ngrx composition root store/ no longer imports lenses/): the metricsLensSource home (node attributeTypes + attributeDescriptors + the metricsLensSource root reducer/selector) the dependencyLensSource home (edge attributeTypes + the dependencyLensSource root) and the domainLensSource home (domain words + the domainLensSource root). Its ngrx store/ internals are reached from outside the owning home ONLY through that home's read/write facade — so the composition root (store/), the load pipeline (load/) and the now-pure-projection lenses all go through the facades, never store/ internals. Replaces the old lens-owns-ccjson-source rule (the source is no longer lens-owned). Spec/e2e/mocks exempt.", from: { path: "^app/", pathNot: [ "^app/codeCharta/stores/metricsLensSource/", "^app/codeCharta/stores/dependencyLensSource/", + "^app/codeCharta/stores/domainLensSource/", "\\.spec\\.ts$", "\\.e2e\\.ts$", "\\.mocks\\.ts$" ] }, to: { - path: ["^app/codeCharta/stores/metricsLensSource/store/", "^app/codeCharta/stores/dependencyLensSource/store/"] + path: [ + "^app/codeCharta/stores/metricsLensSource/store/", + "^app/codeCharta/stores/dependencyLensSource/store/", + "^app/codeCharta/stores/domainLensSource/store/" + ] } }, { name: "lens-no-view-state", severity: "error", comment: - "A lens is data/projection, never a reader of mutable VIEW STATE. Lens code (lenses/**) must not import a state home — stores/mapState, stores/sharedView or stores/preferences — nor any view-state selector; selection/blacklist/edge-visibility reach a lens only as explicit parameters passed by the composing layer (renderModel). This half of the lens‖home fence is why 'Lenses above Stores' is a readability order, not a real edge. Spec/e2e exempt.", + "A lens is data/projection, never a reader of mutable VIEW STATE. Lens code (lenses/**) must not import a state home — stores/mapState, stores/sharedView, stores/preferences or stores/domainBar — nor any view-state selector; selection/blacklist/edge-visibility reach a lens only as explicit parameters passed by the composing layer (renderModel). This half of the lens‖home fence is why 'Lenses above Stores' is a readability order, not a real edge. Spec/e2e exempt.", from: { path: "^app/codeCharta/lenses/", pathNot: ["\\.spec\\.ts$", "\\.e2e\\.ts$"] }, - to: { path: ["^app/codeCharta/stores/mapState/", "^app/codeCharta/stores/sharedView/", "^app/codeCharta/stores/preferences/"] } + to: { + path: [ + "^app/codeCharta/stores/mapState/", + "^app/codeCharta/stores/sharedView/", + "^app/codeCharta/stores/preferences/", + "^app/codeCharta/stores/domainBar/" + ] + } }, /* ───────────────────────────────── stores (state homes + the source) ───────────────────────────────── */ @@ -214,14 +226,15 @@ module.exports = { name: "filestore-has-no-upward-deps", severity: "error", comment: - "FileStore is the source: it sits below every view layer. It must not import lenses or any state home (stores/mapState, stores/sharedView, stores/preferences) so ingestion cannot read back the state they own. Spec/e2e exempt.", + "FileStore is the source: it sits below every view layer. It must not import lenses or any state home (stores/mapState, stores/sharedView, stores/preferences, stores/domainBar) so ingestion cannot read back the state they own. Spec/e2e exempt.", from: { path: "^app/codeCharta/stores/fileStore/", pathNot: ["\\.spec\\.ts$", "\\.e2e\\.ts$"] }, to: { path: [ "^app/codeCharta/lenses/", "^app/codeCharta/stores/mapState/", "^app/codeCharta/stores/sharedView/", - "^app/codeCharta/stores/preferences/" + "^app/codeCharta/stores/preferences/", + "^app/codeCharta/stores/domainBar/" ] } }, @@ -240,14 +253,16 @@ module.exports = { name: "state-home-is-leaf", severity: "error", comment: - "State-home modules — stores/mapState (map-view presentation + metric selection + transient interaction ids), stores/sharedView (focus + search + blacklist + markedPackages), stores/preferences (durable global prefs) — are leaves. They must not import lenses; a lens/renderer/page reads the home facade, never the reverse. The home reads only the model/util kernel + its own store.", + "State-home modules — stores/mapState (map-view presentation + metric selection + transient interaction ids), stores/sharedView (focus + search + blacklist + markedPackages), stores/preferences (durable global prefs), stores/domainBar (word-cloud presentation settings) — and the cc.json source stores (stores/metricsLensSource, stores/dependencyLensSource, stores/domainLensSource) are leaves. They must not import lenses; a lens/renderer/page reads the home facade, never the reverse. The home reads only the model/util kernel + its own store.", from: { path: [ "^app/codeCharta/stores/mapState/", "^app/codeCharta/stores/sharedView/", "^app/codeCharta/stores/preferences/", "^app/codeCharta/stores/metricsLensSource/", - "^app/codeCharta/stores/dependencyLensSource/" + "^app/codeCharta/stores/dependencyLensSource/", + "^app/codeCharta/stores/domainLensSource/", + "^app/codeCharta/stores/domainBar/" ] }, to: { path: ["^app/codeCharta/lenses/"] } @@ -263,7 +278,9 @@ module.exports = { "^app/codeCharta/stores/sharedView/", "^app/codeCharta/stores/preferences/", "^app/codeCharta/stores/metricsLensSource/", - "^app/codeCharta/stores/dependencyLensSource/" + "^app/codeCharta/stores/dependencyLensSource/", + "^app/codeCharta/stores/domainLensSource/", + "^app/codeCharta/stores/domainBar/" ], pathNot: [ "^app/codeCharta/stores/mapState/store/", @@ -271,6 +288,8 @@ module.exports = { "^app/codeCharta/stores/preferences/store/", "^app/codeCharta/stores/metricsLensSource/store/", "^app/codeCharta/stores/dependencyLensSource/store/", + "^app/codeCharta/stores/domainLensSource/store/", + "^app/codeCharta/stores/domainBar/store/", "\\.spec\\.ts$" ] }, @@ -280,13 +299,14 @@ module.exports = { name: "feature-reaches-state-home-only-via-facade", severity: "error", comment: - "Outside code reaches a state home only through its public facades (read/write), never its store/ internals — no raw import of a home's store/**/*.{selector,reducer,actions}. All three homes fenced. The homes' own facades + store/ are exempt (from.pathNot); spec/e2e may wire raw for tests.", + "Outside code reaches a state home only through its public facades (read/write), never its store/ internals — no raw import of a home's store/**/*.{selector,reducer,actions}. All four view-state homes fenced. The homes' own facades + store/ are exempt (from.pathNot); spec/e2e may wire raw for tests.", from: { path: "^app/", pathNot: [ "^app/codeCharta/stores/sharedView/", "^app/codeCharta/stores/preferences/", "^app/codeCharta/stores/mapState/", + "^app/codeCharta/stores/domainBar/", "\\.spec\\.ts$", "\\.e2e\\.ts$" ] @@ -295,7 +315,8 @@ module.exports = { path: [ "^app/codeCharta/stores/sharedView/store/", "^app/codeCharta/stores/preferences/store/", - "^app/codeCharta/stores/mapState/store/" + "^app/codeCharta/stores/mapState/store/", + "^app/codeCharta/stores/domainBar/store/" ] } }, @@ -306,11 +327,11 @@ module.exports = { comment: "A state home may import its OWN store/ internals (its facades re-export them) but must reach a SIBLING home only through that sibling's read/write facade — never the sibling's raw store/. Closes the home→sibling raw-store hole left by the whole-band from.pathNot exemptions in feature-reaches-state-home-only-via-facade / state-home-write-facade-is-sole-dispatch-surface / stores-own-ccjson-source (each exempts the ENTIRE home band as a source, so a sibling could reach past a facade). The $1 back-reference (home captured in from.path) exempts only the SAME home's store/. fileStore is intentionally omitted — it is fenced by filestore-external-access-only-via-facade + filestore-has-no-upward-deps.", from: { - path: "^app/codeCharta/stores/(mapState|sharedView|preferences|metricsLensSource|dependencyLensSource)/", + path: "^app/codeCharta/stores/(mapState|sharedView|preferences|metricsLensSource|dependencyLensSource|domainLensSource|domainBar)/", pathNot: ["\\.spec\\.ts$", "\\.e2e\\.ts$"] }, to: { - path: "^app/codeCharta/stores/(mapState|sharedView|preferences|metricsLensSource|dependencyLensSource)/store/", + path: "^app/codeCharta/stores/(mapState|sharedView|preferences|metricsLensSource|dependencyLensSource|domainLensSource|domainBar)/store/", pathNot: ["^app/codeCharta/stores/$1/store/"] } }, @@ -327,6 +348,7 @@ module.exports = { "^app/codeCharta/stores/preferences/", "^app/codeCharta/stores/sharedView/", "^app/codeCharta/stores/mapState/", + "^app/codeCharta/stores/domainBar/", "\\.spec\\.ts$", "\\.e2e\\.ts$" ] @@ -335,7 +357,8 @@ module.exports = { path: [ "^app/codeCharta/stores/preferences/store/.*\\.actions\\.ts$", "^app/codeCharta/stores/sharedView/store/.*\\.actions\\.ts$", - "^app/codeCharta/stores/mapState/store/.*\\.actions\\.ts$" + "^app/codeCharta/stores/mapState/store/.*\\.actions\\.ts$", + "^app/codeCharta/stores/domainBar/store/.*\\.actions\\.ts$" ] } }, @@ -348,14 +371,16 @@ module.exports = { path: [ "^app/codeCharta/stores/preferences/preferences\\.read\\.facade\\.ts$", "^app/codeCharta/stores/sharedView/sharedView\\.read\\.facade\\.ts$", - "^app/codeCharta/stores/mapState/mapState\\.read\\.facade\\.ts$" + "^app/codeCharta/stores/mapState/mapState\\.read\\.facade\\.ts$", + "^app/codeCharta/stores/domainBar/domainBar\\.read\\.facade\\.ts$" ] }, to: { path: [ "^app/codeCharta/stores/preferences/store/.*\\.actions\\.ts$", "^app/codeCharta/stores/sharedView/store/.*\\.actions\\.ts$", - "^app/codeCharta/stores/mapState/store/.*\\.actions\\.ts$" + "^app/codeCharta/stores/mapState/store/.*\\.actions\\.ts$", + "^app/codeCharta/stores/domainBar/store/.*\\.actions\\.ts$" ] } }, @@ -363,7 +388,7 @@ module.exports = { name: "home-selectors-are-declared-in-their-home", severity: "error", comment: - "A home's ROOT selector (the raw `state => state.` slice accessor: mapStateSelector, preferencesSelector, sharedViewSelector, filesSelector) is a private store/ internal. Only the owning home's own store/ folder may import it — that is where every derived leaf selector (areaMetricSelector, blacklistSelector, isDeltaStateSelector, …) and the home's read window (store/.readWindow.ts) are declared. Handing the root selector out lets a consumer re-derive home state OUTSIDE the home, which duplicates the home's projection surface in feature land and defeats the read facade; this rule mechanically bans createSelector(mapStateSelector, …) in features/. Consumers get a NAMED derived selector or a read-window stream from the home's read facade instead. The facades are deliberately NOT exempt: dependency-cruiser sees only the module edge consumer→facade, so a barrel re-export of the root selector would bypass the fence — hence none of the four barrels re-exports one. Spec/e2e exempt.", + "A home's ROOT selector (the raw `state => state.` slice accessor: mapStateSelector, preferencesSelector, sharedViewSelector, filesSelector, domainBarSelector) is a private store/ internal. Only the owning home's own store/ folder may import it — that is where every derived leaf selector (areaMetricSelector, blacklistSelector, isDeltaStateSelector, …) and the home's read window (store/.readWindow.ts) are declared. Handing the root selector out lets a consumer re-derive home state OUTSIDE the home, which duplicates the home's projection surface in feature land and defeats the read facade; this rule mechanically bans createSelector(mapStateSelector, …) in features/. Consumers get a NAMED derived selector or a read-window stream from the home's read facade instead. The facades are deliberately NOT exempt: dependency-cruiser sees only the module edge consumer→facade, so a barrel re-export of the root selector would bypass the fence — hence none of the five barrels re-exports one. Spec/e2e exempt.", from: { path: "^app/", pathNot: [ @@ -371,6 +396,7 @@ module.exports = { "^app/codeCharta/stores/preferences/store/", "^app/codeCharta/stores/sharedView/store/", "^app/codeCharta/stores/fileStore/store/", + "^app/codeCharta/stores/domainBar/store/", "\\.spec\\.ts$", "\\.e2e\\.ts$" ] @@ -380,7 +406,8 @@ module.exports = { "^app/codeCharta/stores/mapState/store/mapState\\.selector\\.ts$", "^app/codeCharta/stores/preferences/store/preferences\\.selector\\.ts$", "^app/codeCharta/stores/sharedView/store/sharedView\\.selector\\.ts$", - "^app/codeCharta/stores/fileStore/store/files\\.selector\\.ts$" + "^app/codeCharta/stores/fileStore/store/files\\.selector\\.ts$", + "^app/codeCharta/stores/domainBar/store/domainBar\\.selector\\.ts$" ] } }, @@ -394,7 +421,8 @@ module.exports = { path: [ "^app/codeCharta/stores/preferences/preferences\\.write\\.facade\\.ts$", "^app/codeCharta/stores/sharedView/sharedView\\.write\\.facade\\.ts$", - "^app/codeCharta/stores/mapState/mapState\\.write\\.facade\\.ts$" + "^app/codeCharta/stores/mapState/mapState\\.write\\.facade\\.ts$", + "^app/codeCharta/stores/domainBar/domainBar\\.write\\.facade\\.ts$" ] } }, @@ -404,7 +432,7 @@ module.exports = { name: "render-model-is-top-derived", severity: "error", comment: - "renderer/renderModel/ is the cross-lens composing layer: it folds the structure/metrics/dependency lenses + the view-state homes into the decorated tree and its derived read models. It sits ABOVE the lenses and homes — it reads their facades DOWNWARD — so nothing below it may import it back: lenses, stores/fileStore (the source) and the three state homes (stores/mapState, stores/sharedView, stores/preferences) must not depend on it. Consumers ABOVE it (features/, views/, load/, the renderer engine) reach every composing selector through renderModel.facade. Spec/e2e exempt.", + "renderer/renderModel/ is the cross-lens composing layer: it folds the structure/metrics/dependency lenses + the view-state homes into the decorated tree and its derived read models. It sits ABOVE the lenses and homes — it reads their facades DOWNWARD — so nothing below it may import it back: lenses, stores/fileStore (the source), the view-state homes (stores/mapState, stores/sharedView, stores/preferences, stores/domainBar) and the cc.json source stores (stores/metricsLensSource, stores/dependencyLensSource, stores/domainLensSource) must not depend on it. Consumers ABOVE it (features/, views/, load/, the renderer engine) reach every composing selector through renderModel.facade. Spec/e2e exempt.", from: { path: [ "^app/codeCharta/lenses/", @@ -413,7 +441,9 @@ module.exports = { "^app/codeCharta/stores/sharedView/", "^app/codeCharta/stores/preferences/", "^app/codeCharta/stores/metricsLensSource/", - "^app/codeCharta/stores/dependencyLensSource/" + "^app/codeCharta/stores/dependencyLensSource/", + "^app/codeCharta/stores/domainLensSource/", + "^app/codeCharta/stores/domainBar/" ], pathNot: ["\\.spec\\.ts$", "\\.e2e\\.ts$"] }, @@ -473,7 +503,7 @@ module.exports = { name: "load-orchestrator-not-imported-by-lower-layers", severity: "error", comment: - "load/ is the initial-file load orchestrator: on startup it hydrates state from a persisted/URL cc.json by driving the homes, lenses and fileStore through their public facades/actions. It is a TOP layer — nothing it writes into may import it back. Homes (stores/mapState, stores/sharedView, stores/preferences), lenses AND stores/fileStore must not depend on load/. renderer's own upward edges to load/ are fenced by renderer-does-not-import-up. Spec/e2e exempt. PARKED path.", + "load/ is the initial-file load orchestrator: on startup it hydrates state from a persisted/URL cc.json by driving the homes, lenses and fileStore through their public facades/actions. It is a TOP layer — nothing it writes into may import it back. Homes (stores/mapState, stores/sharedView, stores/preferences, stores/domainBar), the cc.json source stores (stores/metricsLensSource, stores/dependencyLensSource, stores/domainLensSource), lenses AND stores/fileStore must not depend on load/. renderer's own upward edges to load/ are fenced by renderer-does-not-import-up. Spec/e2e exempt. PARKED path.", from: { path: [ "^app/codeCharta/stores/mapState/", @@ -482,7 +512,9 @@ module.exports = { "^app/codeCharta/lenses/", "^app/codeCharta/stores/fileStore/", "^app/codeCharta/stores/metricsLensSource/", - "^app/codeCharta/stores/dependencyLensSource/" + "^app/codeCharta/stores/dependencyLensSource/", + "^app/codeCharta/stores/domainLensSource/", + "^app/codeCharta/stores/domainBar/" ], pathNot: ["\\.spec\\.ts$", "\\.e2e\\.ts$"] }, @@ -509,7 +541,9 @@ module.exports = { "^app/codeCharta/lenses/", "^app/codeCharta/stores/fileStore/", "^app/codeCharta/stores/metricsLensSource/", - "^app/codeCharta/stores/dependencyLensSource/" + "^app/codeCharta/stores/dependencyLensSource/", + "^app/codeCharta/stores/domainLensSource/", + "^app/codeCharta/stores/domainBar/" ], pathNot: ["\\.spec\\.ts$", "\\.e2e\\.ts$"] }, diff --git a/visualization/CHANGELOG.md b/visualization/CHANGELOG.md index b28c6db2f1..d24865b8b7 100644 --- a/visualization/CHANGELOG.md +++ b/visualization/CHANGELOG.md @@ -9,14 +9,32 @@ and this project adheres to [Semantic Versioning](http://semver.org/) ### Added 🚀 +- **Domain view (word cloud)**: cc.json **2.0** files carrying a `domain` lens can now be explored as a word cloud. A new **Map | Domain** switcher in the nav bar (shown only when the loaded file has a domain lens) routes between the existing 3D metrics view and a new domain view that renders the selected node's domain words as an ECharts word cloud, colored as a random gradient between the two theme colors. A floating **domain settings bar** adjusts the cloud (shape, size range, rotation range/step, grid size, word cap — default 150 — and frequency/tfidf sizing when the data carries tfidf), and the explorer is reused with its flatten/exclude rules and search bar hidden. The domain words are re-keyed from the analyser node id to the node path at load, and again onto the `/root/` subtrees when several files are aggregated (both mirroring dependency edges); the aggregated map's root carries the combined word banks of all its files. The `?file=…` URL is preserved across a view switch. Opening the domain view without a domain lens — by loading such a file while it is open, or by reaching it via a link — sends you back to the map view instead, so hiding the switcher can never strand you on an empty view. - **cc.json 2.0 support**: The app now loads cc.json **2.0** files (`{ meta, files, lenses }`) in addition to the 1.x format. The 2.0 envelope is auto-detected at the load boundary, validated against the published 2.0 JSON Schema, and mapped into the internal model so the map renders identically to today (node metrics keyed by the stable node `id`; dependency edges read from `lenses.dependency`). Legacy 1.x files are unchanged. ### Changed +- **Word Sizing controls**: **Min Size** and **Max Size** are now **Smallest Word** and **Largest Word**, which is what they actually set — the least and most important word in the cloud render at exactly those sizes, and every other word is spread linearly between them. A new **Fit all words** toggle (off by default) decides what happens to a word the layout cannot place: off leaves it out, so the size range is exact but fewer words than the **Words** count appear; on shrinks it until it fits, so the full count is drawn at the cost of tail words rendering below **Smallest Word**. +- **Domain settings bar areas**: The domain bar was restructured to match the metrics bar — instead of one card and a single "Word Cloud Settings" popover, it now shows one segment per area, each with its own cog popover and its own reset: **Shape**, **Word Sizing** (sizing mode, word count, min/max size and word spacing), and **Rotation** (min/max rotation and rotation step). +- **Word cloud tooltip**: Hovering a word now shows the word in bold followed by **Frequency** and **TF-IDF** on separate lines, instead of a single line naming only the metric that currently drives the sizing. The TF-IDF line is omitted for files that carry no scores. + - **cc.json 2.x versioning**: the app now accepts any major-2 cc.json (`2.0`, `2.1`, …), not only `2.0`. Minors are additive-only, so a file stamped with a newer minor that only adds optional fields still loads; a file that uses a field this build doesn't know is rejected rather than partially loaded (no upward compatibility). A breaking change is a new major (`3.0`), which is refused. +### Changed + +- **Explorer is view-agnostic**: The file explorer no longer belongs to the 3D map. What a row means — whether it can be selected, what hovering it shows, what right-clicking it offers, what its trailing column says — is now supplied by the view that mounts the explorer, and the explorer itself only owns the tree, search, sort, collapse, resize and reveal. The metrics view behaves exactly as before; the domain view now gets domain semantics instead of inheriting map ones. +- **Collapsed explorer**: Collapsing now keeps the width you dragged instead of jumping to a fixed one, and the collapsed bar names the selected node's path with a copy button. Collapse state and width both survive a reload. The sort menu opens in the browser's top layer, so it is no longer clipped by the panel. + +- **Loading spinner is per view**: The full-screen spinner was replaced by one spinner per view. The domain view no longer waits for the 3D map to rebuild, and the map's rebuild is deferred until the metrics view is actually on screen — so switching to the map shows its spinner for exactly as long as that rebuild takes, and working in the domain view is never blocked by a map nobody can see. The nav bar and view switcher stay usable while a view is busy, so a slow view can always be left. + ### Fixed 🐞 +- **Domain view explorer**: Hovering a row in the domain view showed the 3D map's area/height/color metrics; it now shows the node name with its top five domain words, ranked by whichever metric currently drives the cloud's sizing. Right-clicking a row marked it and opened nothing, because the context menu is only mounted in the metrics view; it is now inert there. Files could not be selected at all when the domain view was opened directly (e.g. a `#/domain` link) — selection was gated on a 3D building that view never creates. +- **Selected node is scrolled into view after a load**: Loading a file left the restored selection highlighted somewhere off-screen. Revealing a node also no longer misses when its parent folders have only just been expanded, instead of giving up after a single frame. +- **Hover tooltip follows the theme**: The floating tooltip was hard-coded to a light background and dark text regardless of the active theme. +- **Endless loading spinner on the domain view**: Loading, resetting or re-selecting a map while the domain view was open could leave the spinner up indefinitely, and excluding a node from the explorer could latch it on for the rest of the session. The indicator waited for a 3D map render that the domain view never produces, and its escape hatch skipped the very change it was waiting for. +- **Word cloud in a narrow container**: The cloud can no longer collapse to 1px text when a long word is drawn into a very narrow container — the fitted font-size range is now floored instead of being allowed to degenerate. +- **Word cloud screen-reader description**: The cloud's accessible description now reports how many words are actually drawn, instead of the unlimited total before the word cap is applied, and its word list is ranked by the metric that drives the sizing — in TF-IDF mode it previously named a different set of words than the largest ones on screen. - **Hovered edges respect the selected edge metric**: Hovering or selecting a building no longer draws edges belonging to other edge metrics. On a map carrying several edge metrics (e.g. `dependencies` and `temporal_coupling`), the arrows now match the edge count shown for the building, instead of showing every edge of every metric while the number only counted the selected one. - **Camera auto-fit after load**: Loading or importing a map and applying a scenario no longer intermittently skip the camera reset, leaving the previous view in place. The post-load auto-fit now waits for the new map mesh to be placed into the scene instead of racing the throttled render stream (which reliably missed for `.gz` files), and briefly retries while the geometry is not there yet. - **Camera front view on auto-fit**: The camera reset no longer lands in a skewed "tilted diamond" view when the newly fitted map is much larger than the previous one (or the orbit target had been panned far away). The fit previously let the map controls clamp the fresh camera position against the previous map's zoom limits, locking the off-axis direction in. diff --git a/visualization/app/app.config.spec.ts b/visualization/app/app.config.spec.ts new file mode 100644 index 0000000000..01ce197dc1 --- /dev/null +++ b/visualization/app/app.config.spec.ts @@ -0,0 +1,39 @@ +import { HashLocationStrategy, LocationStrategy } from "@angular/common" +import { TestBed } from "@angular/core/testing" +import { routeLinks, routePaths } from "app/codeCharta/routing/routePaths" +import { DomainViewComponent } from "app/codeCharta/views/domainView/domainView.component" +import { MetricsViewComponent } from "app/codeCharta/views/metricsView/metricsView.component" +import { routerProviders, routes } from "./app.config" + +describe("app routes", () => { + it("should render the metrics view at the default path", () => { + // Arrange & Act + const defaultRoute = routes.find(route => route.path === routePaths.metrics) + + // Assert + expect(defaultRoute?.component).toBe(MetricsViewComponent) + }) + + it("should render the domain view at the domain path", () => { + // Arrange & Act + const domainRoute = routes.find(route => route.path === routePaths.domain) + + // Assert + expect(domainRoute?.component).toBe(DomainViewComponent) + }) + + // The published entry is "…/app/index.html?file=…", which path routing resolves to the location + // "/index.html?file=…" — matching no route, leaving the outlet empty — and static hosting cannot + // rewrite unknown paths, so reloading "/domain" would 404. See the note in app.config. + it("should keep the routed path in the fragment so file and static-host entry URLs resolve", () => { + // Arrange + TestBed.configureTestingModule({ providers: [...routerProviders] }) + + // Act + const locationStrategy = TestBed.inject(LocationStrategy) + + // Assert + expect(locationStrategy).toBeInstanceOf(HashLocationStrategy) + expect(new URL(locationStrategy.prepareExternalUrl(routeLinks.domain)).hash).toBe("#/domain") + }) +}) diff --git a/visualization/app/app.config.ts b/visualization/app/app.config.ts index 85873aaa7b..778f05dd3b 100644 --- a/visualization/app/app.config.ts +++ b/visualization/app/app.config.ts @@ -1,5 +1,7 @@ +import { LocationStrategy } from "@angular/common" import { provideHttpClient, withInterceptorsFromDi } from "@angular/common/http" import { APP_INITIALIZER, ApplicationConfig } from "@angular/core" +import { provideRouter, RouteReuseStrategy, Routes } from "@angular/router" import { provideEffects } from "@ngrx/effects" import { provideStore } from "@ngrx/store" import { ChangelogFacade } from "app/codeCharta/features/changelog/facade" @@ -7,10 +9,45 @@ import { codeMapEffects } from "app/codeCharta/features/codeMap/effects/codeMap. import { fileExtensionBarEffects } from "app/codeCharta/features/fileExtensionBar/effects/fileExtensionBar.effects" import { labelSettingsEffects } from "app/codeCharta/features/labelSettings/effects/labelSettings.effects" import { metricsBarEffects } from "app/codeCharta/features/metricsBar/effects/metricsBar.effects" +import { navBarEffects } from "app/codeCharta/features/navBar/effects/navBar.effects" import { sharedEffects } from "app/codeCharta/features/shared/effects/shared.effects" import { sidebarExplorerEffects } from "app/codeCharta/features/sidebarExplorer/effects/sidebarExplorer.effects" import { loadEffects } from "app/codeCharta/load/effects/load.effects" +import { KeepAliveRouteReuseStrategy } from "app/codeCharta/routing/keepAliveRouteReuse.strategy" +import { QueryPreservingHashLocationStrategy } from "app/codeCharta/routing/queryPreservingHashLocation.strategy" +import { routePaths } from "app/codeCharta/routing/routePaths" import { appReducers, setStateMiddleware } from "app/codeCharta/stores/rootStore/store" +import { DomainViewComponent } from "app/codeCharta/views/domainView/domainView.component" +import { MetricsViewComponent } from "app/codeCharta/views/metricsView/metricsView.component" + +// The metrics (3D map) view is the default path; the domain (word-cloud) view is a sibling route. The +// routed path lives in the URL FRAGMENT (`withHashLocation`), not in the pathname, for two reasons: +// +// 1. The published entry is `…/app/index.html?file=…`. `Location.normalize` only strips a trailing +// "/index.html" (its regex is $-anchored), so with path routing a trailing query string leaves the +// location as "/index.html?file=…" — matching no route, so the outlet stays empty. (Entries without a +// query — Electron's `loadFile(…/index.html)`, the e2e static server's "/" — did resolve correctly.) +// 2. The app is served from static hosting that cannot rewrite unknown paths, so reloading "/domain" 404s. +// A fragment is never sent to the server. +// +// Ownership of the URL is split with QueryParamsService: the router only ever rewrites the FRAGMENT, that +// service only ever rewrites the QUERY STRING, so the ?file=… deep link survives a metrics↔domain switch +// without any router-level query handling. +// +// That split only holds with QueryPreservingHashLocationStrategy in place of the stock +// `withHashLocation()`: Angular's HashLocationStrategy writes a fragment-only RELATIVE url ("#/"), which +// `replaceState` resolves against `document.baseURI` — and `` in index.html strips the +// query from baseURI, so the stock strategy destroyed `?file=…` on the first navigation. See the strategy. +export const routes: Routes = [ + { path: routePaths.metrics, component: MetricsViewComponent }, + { path: routePaths.domain, component: DomainViewComponent } +] + +/** Replaces the stock `withHashLocation()` strategy — see QueryPreservingHashLocationStrategy. */ +export const locationStrategyProvider = { provide: LocationStrategy, useClass: QueryPreservingHashLocationStrategy } + +/** The complete router wiring — always provide these together; the strategy is not optional. */ +export const routerProviders = [provideRouter(routes), locationStrategyProvider] as const export const appConfig: ApplicationConfig = { providers: [ @@ -18,12 +55,18 @@ export const appConfig: ApplicationConfig = { provideStore(appReducers, { metaReducers: [setStateMiddleware] }), + ...routerProviders, + + // Keep the metrics (3D map) and domain views alive across a switch — see the strategy's doc. + { provide: RouteReuseStrategy, useClass: KeepAliveRouteReuseStrategy }, + provideEffects([ ...codeMapEffects, ...metricsBarEffects, ...labelSettingsEffects, ...sidebarExplorerEffects, ...fileExtensionBarEffects, + ...navBarEffects, ...sharedEffects, ...loadEffects ]), diff --git a/visualization/app/codeCharta/e2e/domainView.e2e.ts b/visualization/app/codeCharta/e2e/domainView.e2e.ts new file mode 100644 index 0000000000..636917a6f2 --- /dev/null +++ b/visualization/app/codeCharta/e2e/domainView.e2e.ts @@ -0,0 +1,147 @@ +import { expect, test } from "@playwright/test" +import { CC_URL, clearIndexedDB, goto } from "../../playwright.helper" +import sample1 from "../assets/sample1.cc.json" +import { DomainBarPageObject } from "../features/domainBar/domainBar.po" +import { ViewSwitcherPageObject } from "../features/navBar/components/viewSwitcher/viewSwitcher.po" +import { ExplorerTreeLevelPageObject } from "../features/sidebarExplorer/components/explorerTreeLevel/explorerTreeLevel.po" +import { defaultWordCloudSettings, WordCloudShape } from "../model/wordCloud.model" + +test.describe("DomainView", () => { + test.beforeEach(async ({ page }) => { + await goto(page) + }) + + test.afterEach(async ({ page }) => { + await clearIndexedDB(page) + }) + + test("should switch to the domain view and render the word cloud", async ({ page }) => { + // Arrange + const viewSwitcher = new ViewSwitcherPageObject(page) + + // Act + await viewSwitcher.switchToDomain() + + // Assert + await expect(page.locator("cc-word-cloud canvas")).toBeVisible() + await expect(page.locator("cc-domain-bar")).toBeVisible() + }) + + test("should render the explorer without the rules popovers and search bar in the domain view", async ({ page }) => { + // Arrange + const viewSwitcher = new ViewSwitcherPageObject(page) + + // Act + await viewSwitcher.switchToDomain() + + // Assert + await expect(page.locator("cc-sidebar-explorer")).toBeVisible() + await expect(page.locator("cc-sidebar-explorer cc-explorer-search-bar")).toHaveCount(0) + await expect(page.locator("cc-sidebar-explorer cc-rules-popover")).toHaveCount(0) + }) + + test("should apply a settings change from the domain bar to the state that drives the cloud", async ({ page }) => { + // Arrange — a top-N that is neither the default nor the slider's min/max + const nonDefaultTopN = 30 + const viewSwitcher = new ViewSwitcherPageObject(page) + const domainBar = new DomainBarPageObject(page) + await viewSwitcher.switchToDomain() + await expect(page.locator("cc-word-cloud canvas")).toBeVisible() + + // Act + await domainBar.selectShape(WordCloudShape.star) + await domainBar.setTopN(nonDefaultTopN) + + // Assert — the badge is rendered FROM the domainBar store, so it only shows the new value once the + // slider's change has round-tripped through the store the word cloud reads its settings from. + // (That those settings reach the echarts option is asserted in wordCloud.component.spec.ts — the + // rendered cloud is a canvas, so its layout is not observable from the DOM.) + await expect(domainBar.topNValue()).toHaveText(`${nonDefaultTopN} words`) + // Assert the shape actually changed BEFORE the reset — `circle` is the first