From 2a8c612c3b15907f1d0ade77757dc89f1fac95e6 Mon Sep 17 00:00:00 2001 From: Kirk Bowman Date: Fri, 24 Apr 2026 16:54:04 -0500 Subject: [PATCH 1/2] Fix custom functions in folders not shown by Explode XML - analyze.py: change glob() to rglob() so folder-nested CFs are found; filter out folder separator pseudo-files via stub XML existence check; add folder_path to CF data and index loader - fmcontext.sh: fix sanitized body path lookup to be subfolder-relative (flat lookup silently missed nested CFs, misclassifying all as functional); add FolderPath as 7th column to custom_functions.index header and output; replace mapfile with while-read loop for bash 3.2 compatibility on macOS --- agent/scripts/analyze.py | 46 +++++++++++++++++++++++++++------------- fmcontext.sh | 20 ++++++++++++----- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/agent/scripts/analyze.py b/agent/scripts/analyze.py index 83c3423c..3fb19382 100644 --- a/agent/scripts/analyze.py +++ b/agent/scripts/analyze.py @@ -234,7 +234,8 @@ def load_value_lists_index(solution_dir): def load_custom_functions_index(solution_dir): return _parse_index( solution_dir / "custom_functions.index", - ["name", "id", "parameters", "access", "display", "category"], + ["name", "id", "parameters", "access", "display", "category", + "folder_path"], ) @@ -1429,13 +1430,25 @@ def analyze_custom_functions(solution_name): stub_dir = XML_PARSED_DIR / "custom_function_stubs" / solution_name - cf_files = sorted(cf_dir.glob("*.txt")) + # Recurse into folder subdirectories — CFs organised into FileMaker + # folders are nested in "{FolderName} - ID N/" subdirs, and a flat + # glob would silently skip them. + cf_files = sorted(cf_dir.rglob("*.txt")) functions = {} all_cf_names = set() # First pass: collect names and read all content - cf_data = [] # (name, id, text, param_count) — single pass I/O + cf_data = [] # (name, id, text, param_count, folder_path) — single pass I/O for cf_path in cf_files: + # Folder/separator pseudo-files (e.g. "ACCOUNTS - ID 39.txt", + # "-- - ID 37.txt") are emitted at the flat level by fmparse.sh + # alongside the per-folder subdirs. They have no backing stub XML + # and are not real custom functions — skip them. + rel_parent = cf_path.parent.relative_to(cf_dir) + stub_path = stub_dir / rel_parent / f"{cf_path.stem}.xml" + if not stub_path.exists(): + continue + name = cf_path.stem.rsplit(" - ID ", 1)[0] all_cf_names.add(name) cf_id = None @@ -1448,19 +1461,21 @@ def analyze_custom_functions(solution_name): except (OSError, UnicodeDecodeError): continue - # Read param count from stub XML (ObjectList/@membercount) + # Derive the folder path (parent dirs between the solution root and + # the file), stripping " - ID N" suffixes to match scripts/layouts. + folder_path = re.sub(r' - ID \d+', '', str(rel_parent)) if str(rel_parent) != '.' else '' + + # Read param count from stub XML (ObjectList/@membercount). param_count = 0 - stub_path = stub_dir / f"{cf_path.stem}.xml" - if stub_path.exists(): - try: - stub_text = stub_path.read_text(encoding="utf-8") - mc_match = re.search(r'membercount="(\d+)"', stub_text) - if mc_match: - param_count = int(mc_match.group(1)) - except (OSError, UnicodeDecodeError): - pass + try: + stub_text = stub_path.read_text(encoding="utf-8") + mc_match = re.search(r'membercount="(\d+)"', stub_text) + if mc_match: + param_count = int(mc_match.group(1)) + except (OSError, UnicodeDecodeError): + pass - cf_data.append((name, cf_id, text, param_count)) + cf_data.append((name, cf_id, text, param_count, folder_path)) # Patterns for classification _FIELD_REF_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_ ]*::[A-Za-z_]') @@ -1473,7 +1488,7 @@ def analyze_custom_functions(solution_name): re.IGNORECASE, ) - for name, cf_id, text, param_count in cf_data: + for name, cf_id, text, param_count, folder_path in cf_data: # Find references to other custom functions (substring match) deps = sorted( @@ -1487,6 +1502,7 @@ def analyze_custom_functions(solution_name): "id": cf_id, "param_count": param_count, "line_count": line_count, + "folder_path": folder_path, "dependencies": deps, "text": text, } diff --git a/fmcontext.sh b/fmcontext.sh index 873f0e36..60ca4be9 100755 --- a/fmcontext.sh +++ b/fmcontext.sh @@ -145,7 +145,11 @@ mkdir -p "$CONTEXT_DIR" # Discover solutions to process # --------------------------------------------------------------------------- if [[ -z "$SOLUTION_NAME" ]]; then - mapfile -t SOLUTIONS < <( + # Avoid `mapfile` — it requires bash 4+, and macOS ships bash 3.2. + SOLUTIONS=() + while IFS= read -r _solution_line; do + SOLUTIONS+=("$_solution_line") + done < <( find "$XML_PARSED_DIR" -mindepth 2 -maxdepth 2 -type d \ | sed 's|.*/||' | sort -u ) @@ -392,7 +396,7 @@ for SOLUTION in "${SOLUTIONS[@]}"; do # functional – everything else # --------------------------------------------------------------------------- { - echo "# FunctionName|FunctionID|Parameters|Access|Display|Category" + echo "# FunctionName|FunctionID|Parameters|Access|Display|Category|FolderPath" STUB_DIR="$XML_PARSED_DIR/custom_function_stubs/$SOLUTION" SANITIZED_DIR="$XML_PARSED_DIR/custom_functions_sanitized/$SOLUTION" @@ -404,6 +408,8 @@ for SOLUTION in "${SOLUTIONS[@]}"; do cf_access=$(xval 'string(/CustomFunction/@access)' "$file") cf_display=$(xval 'string(/CustomFunction/Display)' "$file") + folder_path=$(get_folder_path "$file" "$XML_PARSED_DIR/custom_function_stubs") + # Extract parameter names from stub param_count=$(xval 'string(/CustomFunction/ObjectList/@membercount)' "$file") params="" @@ -418,9 +424,13 @@ for SOLUTION in "${SOLUTIONS[@]}"; do done fi - # Classify using the sanitized body + # Classify using the sanitized body — derive the body path from the + # stub path so nested-in-folder CFs resolve correctly (flat lookup + # silently misses them and leaves every folder-nested CF mis- + # classified as "functional"). category="functional" - txt_file="$SANITIZED_DIR/${cf_name} - ID ${cf_id}.txt" + stub_rel="${file#"$STUB_DIR/"}" + txt_file="$SANITIZED_DIR/${stub_rel%.xml}.txt" if [[ -f "$txt_file" ]]; then body=$(<"$txt_file") @@ -451,7 +461,7 @@ for SOLUTION in "${SOLUTIONS[@]}"; do fi fi - echo "${cf_name}|${cf_id}|${params}|${cf_access}|${cf_display}|${category}" + echo "${cf_name}|${cf_id}|${params}|${cf_access}|${cf_display}|${category}|${folder_path}" done fi } > "$SOLUTION_CONTEXT_DIR/custom_functions.index" From e8bdbeb457a1686c8e04c47e24f49a5c4945cedf Mon Sep 17 00:00:00 2001 From: Kirk Bowman Date: Fri, 24 Apr 2026 17:22:34 -0500 Subject: [PATCH 2/2] Fix trace.py missing CFs in subfolders and zero-arg CF detection - build_cf_names: use rglob instead of iterdir so custom functions nested inside folder-group subdirectories are discovered - parse_scripts: extend bracket guard to include lines with ']' so zero-argument CF calls on expression-continuation lines are not skipped Made-with: Cursor --- agent/scripts/trace.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/agent/scripts/trace.py b/agent/scripts/trace.py index 8ff25f86..cf3b9ccd 100644 --- a/agent/scripts/trace.py +++ b/agent/scripts/trace.py @@ -212,12 +212,11 @@ def build_cf_names(solution_name): cfs = [] if not cf_dir.exists(): return cfs - for f in cf_dir.iterdir(): - if f.suffix == ".txt": - # Parse "FuncName - ID NNN.txt" - m = re.match(r'^(.+?)\s*-\s*ID\s+(\d+)\.txt$', f.name) - if m: - cfs.append({"name": m.group(1), "id": m.group(2), "path": f}) + for f in cf_dir.rglob("*.txt"): + # Parse "FuncName - ID NNN.txt" + m = re.match(r'^(.+?)\s*-\s*ID\s+(\d+)\.txt$', f.name) + if m: + cfs.append({"name": m.group(1), "id": m.group(2), "path": f}) return cfs @@ -383,7 +382,7 @@ def parse_scripts(solution_name, scripts_index, to_map, cf_names): )) # --- Custom function references in expressions --- - if "[" in line: # Only scan lines with parameters + if "[" in line or "]" in line: # Catch both opening and continuation lines for cf in cf_name_set: # Match CF name with parens (function call) or standalone (zero-param) pattern = re.compile(