Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/CI_master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@
changedPythonFiles: ${{ needs.Prepare.outputs.changedPythonFiles }}
changedPythonLines: ${{ needs.Prepare.outputs.changedPythonLines }}

AdditionalChecks:
needs: [ Prepare ]
uses: ./.github/workflows/actions/additionalChecks/added_dependency.yml

WrapUp:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {}
needs: [
Prepare,
Pixi,
Ubuntu,
Windows,
Lint
Lint,
AdditionalChecks
]
if: always()
uses: ./.github/workflows/sub_wrapup.yml
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/actions/additionalChecks/added_dependency.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# SPDX-License-Identifier: LGPL-2.1-or-later

name: Check for added dependencies

on:
pull_request:
branches: [main]

jobs:
check_added_dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check for added C++ header file dependencies
run: |
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }}
git diff | python3 .github/workflows/actions/additionalChecks/added_dependency.py
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,11 @@ repos:
rev: 317810f3c6a0ad3572367dc86cb6e41863e16e08 # frozen: v21.1.5
hooks:
- id: clang-format
- repo: local
hooks:
- id: added_dependency
name: check for new FreeCAD inter-module dependencies
entry: python tools/lint/added_dependency.py
language: python
files: \.(cpp|h|py|pyi)$
pass_filenames: true
4 changes: 2 additions & 2 deletions cMake/FreeCAD_Helpers/CheckInterModuleDependencies.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ macro(CheckInterModuleDependencies)
REQUIRES_MODS(BUILD_BIM BUILD_PART BUILD_MESH BUILD_MESH_PART BUILD_DRAFT)
REQUIRES_MODS(BUILD_DRAFT BUILD_SKETCHER BUILD_TECHDRAW)
REQUIRES_MODS(BUILD_DRAWING BUILD_PART BUILD_SPREADSHEET)
REQUIRES_MODS(BUILD_FEM BUILD_PART)
REQUIRES_MODS(BUILD_FEM BUILD_PART BUILD_MESH BUILD_PLOT)
REQUIRES_MODS(BUILD_IMPORT BUILD_PART BUILD_PART_DESIGN)
REQUIRES_MODS(BUILD_INSPECTION BUILD_MESH BUILD_POINTS BUILD_PART)
REQUIRES_MODS(BUILD_JTREADER BUILD_MESH)
Expand All @@ -30,7 +30,7 @@ macro(CheckInterModuleDependencies)
REQUIRES_MODS(BUILD_OPENSCAD BUILD_MESH_PART BUILD_DRAFT)
REQUIRES_MODS(BUILD_MATERIAL_EXTERNAL BUILD_MATERIAL)
REQUIRES_MODS(BUILD_MEASURE BUILD_PART)
REQUIRES_MODS(BUILD_PART BUILD_MATERIAL)
REQUIRES_MODS(BUILD_PART BUILD_MATERIAL BUILD_SHOW)
REQUIRES_MODS(BUILD_PART_DESIGN BUILD_SKETCHER)
# REQUIRES_MODS(BUILD_CAM BUILD_PART BUILD_MESH BUILD_ROBOT)
REQUIRES_MODS(BUILD_CAM BUILD_PART BUILD_MESH)
Expand Down
230 changes: 230 additions & 0 deletions tools/lint/added_dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: LGPL-2.1-or-later

"""Script to parse changed files and determine if any new C++ dependencies were added between
FreeCAD modules."""

import argparse

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'argparse' is not used.

Copilot Autofix

AI 16 days ago

To fix an unused-import issue, the general solution is to remove the import statement for any module that is not referenced anywhere in the file. This reduces clutter and avoids implying dependencies that are not actually required.

In this file, the argparse module imported on line 7 is not used in the provided code, while command-line handling is done via sys.argv. The safest, non‑functional change is to delete only the import argparse line and leave the rest of the imports untouched. No additional methods or definitions are required.

Concretely, in tools/lint/added_dependency.py, remove line 7 (import argparse) and do not replace it with anything. All remaining imports (os, re, sys, and the utils imports) stay as they are.

Suggested changeset 1
tools/lint/added_dependency.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tools/lint/added_dependency.py b/tools/lint/added_dependency.py
--- a/tools/lint/added_dependency.py
+++ b/tools/lint/added_dependency.py
@@ -4,7 +4,6 @@
 """Script to parse changed files and determine if any new C++ dependencies were added between
 FreeCAD modules."""
 
-import argparse
 import os
 import re
 import sys
EOF
@@ -4,7 +4,6 @@
"""Script to parse changed files and determine if any new C++ dependencies were added between
FreeCAD modules."""

import argparse
import os
import re
import sys
Copilot is powered by AI and may make mistakes. Always verify output.
import os
import re
import sys

from utils import (
init_environment,
write_file,
append_file,
emit_problem_matchers,
add_common_arguments,
)
Comment on lines +12 to +18

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'init_environment' is not used.
Import of 'write_file' is not used.
Import of 'append_file' is not used.
Import of 'emit_problem_matchers' is not used.
Import of 'add_common_arguments' is not used.

Copilot Autofix

AI 16 days ago

To fix unused-import issues, remove the unused names from the import statement (or remove the entire import if none of its names are needed). This eliminates the unnecessary dependency and clarifies which modules the file actually relies on.

In this file, the only use of utils is the from utils import (...) block on lines 12–18. Since CodeQL reports every imported symbol as unused and no usage is visible in the snippet, the best, non‑behavior‑changing fix is to delete this entire from utils import (...) block. No other code changes are needed, and no new imports or helper methods are required.

Concretely: in tools/lint/added_dependency.py, delete lines 12–18 containing the multi-line import from utils, leaving the rest of the file unchanged.

Suggested changeset 1
tools/lint/added_dependency.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tools/lint/added_dependency.py b/tools/lint/added_dependency.py
--- a/tools/lint/added_dependency.py
+++ b/tools/lint/added_dependency.py
@@ -9,14 +9,6 @@
 import re
 import sys
 
-from utils import (
-    init_environment,
-    write_file,
-    append_file,
-    emit_problem_matchers,
-    add_common_arguments,
-)
-
 KNOWN_MODULES = [
     "app",
     "base",
EOF
@@ -9,14 +9,6 @@
import re
import sys

from utils import (
init_environment,
write_file,
append_file,
emit_problem_matchers,
add_common_arguments,
)

KNOWN_MODULES = [
"app",
"base",
Copilot is powered by AI and may make mistakes. Always verify output.

KNOWN_MODULES = [
"app",
"base",
"gui",
"addonmanager",
"assembly",
"bim",
"cam",
"cloud",
"draft",
"fem",
"help",
"idf",
"import",
"inspection",
"jtreader",
"material",
"measure",
"mesh",
"meshpart",
"openscad",
"part",
"partdesign",
"plot",
"points",
"reverseengineering",
"robot",
"sandbox",
"show",
"sketcher",
"spreadsheet",
"start",
"surface",
"techdraw",
"templatepymod",
"test",
"tux",
"web",
]


def parse_diff_by_file(diff_content: str):
"""
Parse git diff output and return a dictionary mapping filenames to their diffs.

Returns:
dict: {filename: diff_chunk}
"""
file_diffs = {}
current_file = None
current_chunk = []

lines = diff_content.split("\n")

for line in lines:
if line.startswith("diff --git"):
if current_file and current_chunk:
file_diffs[current_file] = "\n".join(current_chunk)

match = re.search(r"diff --git a/(.*?) b/", line)
if match:
current_file = match.group(1)
current_chunk = [line]
else:
current_file = None
current_chunk = []
elif current_file is not None:
current_chunk.append(line)

if current_file and current_chunk:
file_diffs[current_file] = "\n".join(current_chunk)

return file_diffs


def check_module_compatibility(
file_module: str, included_file_module: str, intermodule_dependencies: dict[str, list[str]]
) -> bool:
if file_module == included_file_module:
return True
if file_module not in KNOWN_MODULES or included_file_module not in KNOWN_MODULES:
return True # We are only checking compatibility between modules *in FreeCAD*
if file_module in intermodule_dependencies:
return included_file_module in intermodule_dependencies[file_module]
else:
return False


def load_intermodule_dependencies(cmake_file_path: str) -> dict[str, list[str]]:
"""FreeCAD already has a file that defines the known dependencies between modules. The basic rule is that no NEW
dependencies can be added (without extensive discussion with the core developers). This function loads that file
and parses it such that we can use it to check if a new dependency was added."""
dependencies = {
"base": [],
"app": ["base"],
"gui": ["app", "base"]
}

if not os.path.exists(cmake_file_path):
print(f"ERROR: {cmake_file_path} not found", file=sys.stderr)
exit(1)

with open(cmake_file_path, "r") as f:
content = f.read()

pattern = r"REQUIRES_MODS\(\s*(\w+)((?:\s+\w+)*)\s*\)"
matches = re.finditer(pattern, content)
for match in matches:
dependent = match.group(1)
prerequisites = match.group(2).split()
module_name = dependent.replace("BUILD", "").replace("_", "").lower()
prereq_names = [p.replace("BUILD", "").replace("_", "").lower() for p in prerequisites]
prereq_names.append("app") # Everything in this list depends on (or is allowed to depend on) App
dependencies[module_name] = prereq_names

for KNOWN_MODULE in KNOWN_MODULES:
if KNOWN_MODULE not in dependencies:
dependencies[KNOWN_MODULE] = ["app", "base"] # NOT Gui, which must be handled separately

resolve_transitive_dependencies(dependencies)

#print()
#print("Recognized intermodule dependencies")
#print("-----------------------------------")
#for module, deps in dependencies.items():
# print(f"{module} depends on: {', '.join(deps)}")
#print()

return dependencies

def resolve_transitive_dependencies(dependencies: dict[str, list[str]]) -> None:
changed = True
while changed:
changed = False
for module, deps in dependencies.items():
for dep in list(deps): # copy to avoid mutation during iteration
for transitive in dependencies.get(dep, []):
if transitive not in deps:
deps.append(transitive)
changed = True



def check_file_dependencies(
file: str, intermodule_dependencies: dict[str, list[str]]
) -> bool:
"""Returns true if the dependencies are OK, or false if they are not."""
file_module = file.split("/")[1] # src/Gui, etc.
if file_module == "Mod":
file_module = file.split("/")[2] # src/Mod/Part, etc.
is_gui = "gui" in [x.lower() for x in file.split("/")]
with open(file, "r", encoding="utf-8") as f:
content = f.read()
lines = content.splitlines()
failed = False
line_number = 0
for line in lines:
line_number += 1
if file.endswith(".h") or file.endswith(".cpp"):
include_file = (m := re.search(r'^\s*#include\s*[<"]([^>"]+)[>"]', line)) and m.group(
1
)
if include_file:
include_file_module = include_file.split("/")[0]
if include_file_module == "Mod":
include_file_module = include_file.split("/")[1]
else:
include_file_module = None
elif file.endswith(".py") or file.endswith(".pyi"):
include_file_module = (
m := re.search(
r"^\s*(?:from\s+([\w.]+)\s+)?import\s+([\w.]+(?:\s+as\s+\w+)?(?:\s*,\s*[\w.]+(?:\s+as\s+\w+)?)*)",
line,
)
) and (m.group(1) or m.group(2))
else:
return True
if not include_file_module:
continue
if is_gui and include_file_module.lower() == "gui":
# Special case: anything with "gui" in its path is allowed to include Gui
continue
compatibility = check_module_compatibility(
file_module.lower(), include_file_module.lower(), intermodule_dependencies
)

if not compatibility:
print(
f" --> {file_module} added a new dependency on {include_file_module} in {file} line {line_number}",
file=sys.stderr,
)
failed = True
return not failed


def main():

dependencies = load_intermodule_dependencies(
"cMake/FreeCAD_Helpers/CheckIntermoduleDependencies.cmake"
)
failed_files = []
for file in sys.argv[1:]:
if not check_file_dependencies(file, dependencies):
failed_files.append(file)
if failed_files:
sys.exit(1)
sys.exit(0)


if __name__ == "__main__":
main()