4. Line breaks and indentation
5. Cross-language guidance
-* 1. Naming Conventions
+* Identifer Naming Conventions
-** 1.1 Identifier Naming
+** Identifier Naming
- Types, modules: *PascalCase*
- Functions, variables: *snake_case*
- Globals: UPPER_SNAKE_CASE
-** 1.2 Suffix Semantics
+** Proper Noun and Acronyms
+
+Even in PascalCase and snake_case, they remain capitalized, as per the English language convention.
+
+E.g.s
+
+- IEEE_publication_count
+- person_Sara_novelties_list
+
+
+** Suffix Semantics
Optionally suffixes are added to variable names to suggest type or interface.
- =*_dp :: directory path, not specified if relative or absolute
- =*_fpr :: relative file path
- =*_fpa :: absolute file path
+If the file system node type is not specifically specified
+
+- =*_fs_nod_p :: file system node path, not specified if relative or absolute
+- =*_fs_nod_pr :: relative file system node path
+- =*_fs_nod_pa :: absolute file system node path
+
- =*_list= :: generic ordered items
- =*_seq= :: ordered items accessed by index
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+A_minus_B - CLI test driver for skeleton.tree_dict_A_minus_B(A, B)
+
+Usage:
+ A_minus_B <A_root> <B_root>
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from typing import Sequence
+
+import meta
+import skeleton
+
+
+def CLI(argv: Sequence[str] | None = None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+
+ prog = os.path.basename(sys.argv[0]) if sys.argv else "A_minus_B"
+
+ if len(argv) != 2 or argv[0] in ("-h", "--help"):
+ print(f"Usage: {prog} <A_root> <B_root>")
+ return 1
+
+ A_root = argv[0]
+ B_root = argv[1]
+
+ if not os.path.isdir(A_root):
+ print(f"{prog}: {A_root}: not a directory")
+ return 2
+
+ if not os.path.isdir(B_root):
+ print(f"{prog}: {B_root}: not a directory")
+ return 3
+
+ A = skeleton.tree_dict_make(A_root, None)
+ B = skeleton.tree_dict_make(B_root, None)
+
+ meta.debug_set("tree_dict_A_minus_B")
+
+ _result = skeleton.tree_dict_A_minus_B(A, B)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+CLI.py - Harmony skeleton checker
+
+Grammar (informal):
+
+ check <command>* [<other>]
+
+ <command> :: <help> | <no_other> | <has_other>
+
+ <help> :: version | help | usage
+ <no_other> :: environment
+ <has_other> :: structure | import | export | suspicious | addendum | all
+
+Commands are sorted into three sets:
+ 1. HELP_COMMANDS
+ 2. NO_OTHER_COMMANDS
+ 3. HAS_OTHER_COMMANDS
+
+At runtime, argv commands are classified into four lists:
+ 1. help_list
+ 2. no_other_list
+ 3. has_other_list
+ 4. unclassified_list
+
+If the meta debug set contains the tag "print_command_lists", these four lists
+are printed.
+
+If 'environment' appears in no_other_list, the meta.printenv() helper
+is invoked to print the environment.
+
+For <has_other> commands we compare:
+
+ A = Harmony skeleton tree_dict
+ B = <other> project tree_dict (path is the last argv token when any
+ <has_other> is present before it).
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from typing import Sequence
+
+import command
+import doc
+import Harmony
+import meta
+import skeleton
+
+# meta.debug_set("print_command_lists")
+
+# Command tag sets (classification universe)
+HELP_COMMANDS: set[str] = set([
+ "version"
+ ,"help"
+ ,"usage"
+])
+
+NO_OTHER_COMMANDS: set[str] = set([
+ "environment"
+])
+
+HAS_OTHER_COMMANDS: set[str] = set([
+ "structure"
+ ,"import"
+ ,"export"
+ ,"suspicious"
+ ,"addendum"
+ ,"all"
+])
+
+
+def command_type(arg: str) -> str:
+ """
+ Classify a single command token.
+
+ Returns:
+ "Help" if arg is a help command
+ "NoOther" if arg is a no_other command
+ "HasOther" if arg is a has_other command
+ "UnClassified" otherwise
+ """
+ if arg in HELP_COMMANDS:
+ return "Help"
+
+ if arg in NO_OTHER_COMMANDS:
+ return "NoOther"
+
+ if arg in HAS_OTHER_COMMANDS:
+ return "HasOther"
+
+ return "UnClassified"
+
+
+def print_command_lists(
+ help_list: list[str]
+ ,no_other_list: list[str]
+ ,has_other_list: list[str]
+ ,unclassified_list: list[str]
+) -> None:
+ """
+ Print the four classified command lists derived from argv.
+ """
+ print("help_list:", help_list)
+ print("no_other_list:", no_other_list)
+ print("has_other_list:", has_other_list)
+ print("unclassified_list:", unclassified_list)
+
+
+def CLI(argv: Sequence[str] | None = None) -> int:
+ """
+ CLI entrypoint.
+
+ Responsibilities:
+ 1. Accept argv (or sys.argv[1:] by default).
+ 2. Classify arguments using command_type(), with the last argument
+ treated specially to avoid aliasing.
+ 3. Invoke behaviors implied by the commands.
+ 4. Return integer status code.
+
+ Argument interpretation:
+
+ Let argv = [a0, a1, ..., aN-1].
+
+ - If N == 0:
+ no commands; nothing to do.
+
+ - If N >= 1:
+ * Classify a0..aN-2.
+ - If any are UnClassified -> error.
+
+ * If any <has_other> appear in a0..aN-2:
+ - aN-1 is treated as <other> path (B_root), not classified.
+
+ * If no <has_other> appear in a0..aN-2:
+ - Classify aN-1:
+ - If UnClassified -> error (unknown command).
+ - If HasOther -> error (other path not specified).
+ - Else -> added to Help / NoOther lists.
+ """
+ if argv is None:
+ argv = sys.argv[1:]
+
+ # No arguments: print usage and exit with status 1.
+ if len(argv) == 0:
+ doc.print_usage()
+ return 1
+
+ # No arguments: nothing to do (could later decide to print usage).
+ if len(argv) == 0:
+ return 0
+
+ # Split into head (all but last) and last argument
+ head = argv[:-1]
+ last = argv[-1]
+
+ help_list: list[str] = []
+ no_other_list: list[str] = []
+ has_other_list: list[str] = []
+ unclassified_list: list[str] = []
+
+ # 1. Classify head tokens
+ for arg in head:
+ ct = command_type(arg)
+
+ if ct == "Help":
+ help_list.append(arg)
+ elif ct == "NoOther":
+ no_other_list.append(arg)
+ elif ct == "HasOther":
+ has_other_list.append(arg)
+ else:
+ unclassified_list.append(arg)
+
+ # Any unclassified in the head is an error
+ if len(unclassified_list) > 0:
+ first_bad = unclassified_list[0]
+ print(f"Unrecognized command: {first_bad}")
+ return 5
+
+ head_has_other = (len(has_other_list) > 0)
+
+ B_root: str | None = None
+
+ if head_has_other:
+ # 2A. Any <has_other> in head -> last arg is always <other> path.
+ B_root = os.path.abspath(last)
+ else:
+ # 2B. No <has_other> in head -> classify last.
+ ct = command_type(last)
+
+ if ct == "UnClassified":
+ print(f"Unrecognized command: {last}")
+ return 5
+
+ if ct == "HasOther":
+ print("Other path not specified for has_other command(s).")
+ return 6
+
+ if ct == "Help":
+ help_list.append(last)
+ elif ct == "NoOther":
+ no_other_list.append(last)
+ # ct cannot be HasOther here due to earlier check.
+
+ if meta.debug_has("print_command_lists"):
+ print_command_lists(
+ help_list
+ ,no_other_list
+ ,has_other_list
+ ,unclassified_list
+ )
+
+ # Help handling
+ if len(help_list) > 0:
+ if "version" in help_list:
+ meta.version_print()
+ if "usage" in help_list:
+ doc.print_usage()
+ if "help" in help_list:
+ doc.print_help()
+ return 1
+
+ ret_val = 0
+
+ # No-other commands (environment, etc.)
+ if "environment" in no_other_list:
+ env_status = meta.printenv()
+ if env_status != 0:
+ ret_val = env_status
+
+ # If we still have no has_other commands, we are done.
+ # (Example: just "environment", or just "help/usage".)
+ if len(has_other_list) == 0:
+ return ret_val
+
+ # At this point we know:
+ # - has_other_list is non-empty
+ # - B_root must have been set (head_has_other was True)
+ if B_root is None:
+ print("Internal error: B_root not set despite has_other commands.")
+ return 7
+
+ if not os.path.isdir(B_root):
+ print(f"Other project path is not a directory: {B_root}")
+ return 4
+
+ # Determine Harmony root (A_root)
+ status, A_root = Harmony.where()
+
+ if status == "not-found":
+ print("Harmony project not found; normally this command is run from within Harmony.")
+ return 3
+
+ if status == "different":
+ print("Seems we are not running in the Harmony project, will exit.")
+ return 2
+
+ # Build tree_dicts for A (Harmony) and B (other project)
+ A_tree = skeleton.tree_dict_make(A_root, None)
+ B_tree = skeleton.tree_dict_make(B_root, None)
+
+ # Dispatch the <has_other> commands
+ cmd_status = command.dispatch(
+ has_other_list
+ ,A_tree
+ ,B_tree
+ ,A_root
+ ,B_root
+ )
+
+ if cmd_status != 0:
+ ret_val = cmd_status
+
+ return ret_val
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+GitIgnore.py - minimal .gitignore-based helper for Harmony projects
+
+Behavior:
+
+ 1. During initialization, traverse the project tree rooted at
+ <project_path>.
+
+ 2. Whenever a directory contains a '.gitignore' file, record:
+ - its relative directory path from the project root
+ - a list of regular expressions compiled from the patterns
+ in that '.gitignore' file
+
+ These are stored in:
+
+ self.rules: Dict[str, List[Pattern]]
+
+ where the key is the directory RELATIVE to the project root:
+ "" -> project root (top-level .gitignore)
+ "src" -> src/.gitignore
+ "src/module" -> src/module/.gitignore
+
+ 3. check(<path>) -> token:
+
+ - <path> is a path relative to the project root.
+
+ - We compute all prefix directories of <path>, including the
+ root (""), for example:
+
+ path = "a/b/c.py"
+ prefixes = ["", "a", "a/b"]
+
+ - For each prefix, if there are regexes stored for that directory,
+ we collect them.
+
+ - We then test ALL collected regexes against the basename of
+ <path> (the last component only).
+
+ - If ANY regex matches, return 'Ignore'.
+ Otherwise return 'Accept'.
+
+Notes:
+
+ * We implement a simplified subset of .gitignore semantics suitable
+ for your current patterns and add a small base ignore set for
+ always-ignored names such as '.git'.
+"""
+
+from __future__ import annotations
+
+import fnmatch
+import os
+import re
+from typing import Dict, List
+import Harmony
+
+
+class GitIgnore:
+ """
+ GitIgnore(project_path)
+
+ Attributes:
+ project_path:
+ Absolute path to the project root.
+
+ rules:
+ Mapping from relative directory path -> list of compiled regex
+ patterns derived from that directory's '.gitignore' file.
+
+ Example:
+ rules[""] -> patterns from <root>/.gitignore
+ rules["developer"] -> patterns from developer/.gitignore
+
+ base_patterns:
+ List of compiled regex patterns applied to the basename of every
+ checked path, independent of any .gitignore file. Currently used
+ to always ignore '.git' directories.
+ """
+
+ def __init__(
+ self
+ ,project_path: str
+ ) -> None:
+ """
+ Initialize a GitIgnore instance with a path to a project and
+ scan for '.gitignore' files.
+ """
+ self.project_path: str = os.path.abspath(project_path)
+ self.rules: Dict[str, List[re.Pattern]] = {}
+
+ # Base patterns: always applied, regardless of .gitignore contents.
+ # These are matched against basenames only.
+ self.base_patterns: List[re.Pattern] = [
+ re.compile(r"^\.git$") # ignore any basename == ".git"
+ ]
+
+ self._scan_project()
+
+ def _scan_project(self) -> None:
+ """
+ Traverse the project tree and populate self.rules with entries of
+ the form:
+
+ <rel_dir> -> [Pattern, Pattern, ...]
+
+ where <rel_dir> is the directory containing '.gitignore', relative
+ to the project root ("" for root).
+ """
+ root = self.project_path
+
+ for dirpath, dirnames, filenames in os.walk(root, topdown=True):
+ if ".gitignore" not in filenames:
+ continue
+
+ rel_dir = os.path.relpath(dirpath, root)
+ if rel_dir == ".":
+ rel_dir = ""
+
+ gitignore_path = os.path.join(dirpath, ".gitignore")
+ patterns = self._parse_gitignore_file(gitignore_path)
+
+ if patterns:
+ if rel_dir not in self.rules:
+ self.rules[rel_dir] = []
+ self.rules[rel_dir].extend(patterns)
+
+ def _parse_gitignore_file(
+ self
+ ,gitignore_path: str
+ ) -> List[re.Pattern]:
+ """
+ Parse a single '.gitignore' file into a list of compiled regex patterns.
+
+ Simplified rules:
+ - Blank lines and lines starting with '#' are ignored.
+ - Lines containing '/' in the MIDDLE are currently ignored
+ (future extension).
+ - Lines ending with '/' are treated as directory name patterns:
+ '__pycache__/' -> pattern on basename '__pycache__'
+ - All patterns are treated as name globs and compiled via
+ fnmatch.translate(), to be matched against basenames only.
+ """
+ patterns: List[re.Pattern] = []
+
+ try:
+ with open(gitignore_path, "r", encoding="utf-8") as f:
+ for raw_line in f:
+ line = raw_line.strip()
+
+ # Skip comments and blank lines
+ if not line or line.startswith("#"):
+ continue
+
+ # Remove trailing '/' for directory patterns (e.g. '__pycache__/')
+ if line.endswith("/"):
+ line = line[:-1].strip()
+ if not line:
+ continue
+
+ # If there is still a '/' in the line, we do not support this
+ # pattern in this minimal implementation.
+ if "/" in line:
+ continue
+
+ # Compile as a name glob -> regex
+ regex_text = fnmatch.translate(line)
+ patterns.append(re.compile(regex_text))
+
+ except OSError:
+ # If the .gitignore cannot be read, just skip it.
+ return patterns
+
+ return patterns
+
+ def check(
+ self
+ ,path: str
+ ) -> str:
+ """
+ Check a path against the collected .gitignore patterns.
+
+ path:
+ A path relative to the project root.
+
+ Returns:
+ 'Ignore' if any applicable pattern matches the basename of the path,
+ otherwise 'Accept'.
+ """
+ # Normalize the incoming path
+ norm = os.path.normpath(path)
+
+ # If the path is '.' or empty, we accept it
+ if norm in ("", "."):
+ return "Accept"
+
+ basename = os.path.basename(norm)
+
+ # First, apply base patterns (always applied).
+ for pat in self.base_patterns:
+ if pat.match(basename):
+ return "Ignore"
+
+ # Build the list of directories that may contribute .gitignore rules.
+ #
+ # For path "a/b/c":
+ # prefixes: ["", "a", "a/b"]
+ parts = norm.split(os.sep)
+
+ prefixes: List[str] = [""]
+ prefix = None
+ for part in parts[:-1]:
+ if prefix is None:
+ prefix = part
+ else:
+ prefix = os.path.join(prefix, part)
+ prefixes.append(prefix)
+
+ # Collect all patterns from the applicable .gitignore directories
+ for rel_dir in prefixes:
+ dir_patterns = self.rules.get(rel_dir)
+ if not dir_patterns:
+ continue
+
+ for pat in dir_patterns:
+ if pat.match(basename):
+ return "Ignore"
+
+ return "Accept"
+
+
+def test_GitIgnore() -> int:
+ """
+ 1. Locate the Harmony project root using Harmony.where().
+ 2. Create a GitIgnore instance rooted at that path.
+ 3. Print:
+ - directories that have .gitignore rules
+ - directories (relative) that would be ignored by check()
+ """
+ status, Harmony_root = Harmony.where()
+
+ if status == "not-found":
+ print("Harmony project not found; cannot test GitIgnore.")
+ return 1
+
+ if status == "different":
+ print("Warning: Harmony not found, using nearest .git directory for GitIgnore test.")
+
+ gi = GitIgnore(Harmony_root)
+
+ print(".gitignore rule directories (relative to Harmony root):")
+ for rel_dir in sorted(gi.rules.keys()):
+ print(f" {rel_dir if rel_dir else '.'}")
+
+ print("\nDirectories that would be ignored (relative to Harmony root):")
+ for dirpath, dirnames, filenames in os.walk(Harmony_root, topdown=True):
+ rel_dir = os.path.relpath(dirpath, Harmony_root)
+ if rel_dir == ".":
+ rel_dir = ""
+
+ if gi.check(rel_dir) == "Ignore":
+ print(f" {rel_dir if rel_dir else '.'}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(test_GitIgnore())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+locate the project root
+"""
+
+from __future__ import annotations
+
+import meta
+import os
+import sys
+from typing import Any, Callable, Dict
+
+# where
+#
+# Context / assumptions:
+# 1. This module lives somewhere under the Harmony tree, for example:
+# /.../Harmony/tool/skeleton/skeleton.py
+# 2. CLI.py is run from somewhere inside the same tree (or a clone).
+#
+# Search behavior:
+# 1. Start from the directory containing this file.
+# 2. Walk upward towards the filesystem root, with limits:
+# a) Do not move up more than 5 levels.
+# b) Stop immediately if the current directory contains a
+# '.git' subdirectory.
+#
+# Result classification:
+# status is one of:
+# 'found' -> we found a directory whose basename is 'Harmony'
+# 'different' -> we stopped at a directory that has a '.git'
+# subdirectory, but its basename is not 'Harmony'
+# 'not-found' -> we hit the 5-level limit or filesystem root
+# without finding 'Harmony' or a '.git' directory
+#
+# Path:
+# - In all cases, the returned path is the last directory inspected:
+# * the 'Harmony' directory (status 'found'), or
+# * the directory with '.git' (status 'different'), or
+# * the directory at the 5-level limit / filesystem root
+# (status 'not-found').
+#
+# Debug printing:
+# - If meta.debug_has("print_Harmony_root") is true, print:
+# * "The Harmony project root found at: {path}"
+# when status == 'found'
+# * "Harmony not found, but found: {path}"
+# when status == 'different'
+# * "Harmony not found."
+# when status == 'not-found'
+def where() -> tuple[str, str]:
+ """
+ Locate the Harmony root (or best guess).
+
+ Returns:
+ (status, path)
+ """
+ here = os.path.abspath(__file__)
+ d = os.path.dirname(here)
+
+ harmony_root = None
+ status = "not-found"
+
+ max_up = 5
+ steps = 0
+
+ while True:
+ base = os.path.basename(d)
+
+ # Case 1: exact 'Harmony' directory name
+ if base == "Harmony":
+ harmony_root = d
+ status = "found"
+ break
+
+ # Case 2: stop at a directory that has a '.git' subdirectory
+ git_dir = os.path.join(d, ".git")
+ if os.path.isdir(git_dir):
+ harmony_root = d
+ if base == "Harmony":
+ status = "found"
+ else:
+ status = "different"
+ break
+
+ parent = os.path.dirname(d)
+
+ # Stop if we hit filesystem root
+ if parent == d:
+ harmony_root = d
+ status = "not-found"
+ break
+
+ steps += 1
+ if steps > max_up:
+ # Reached search depth limit; last inspected directory is d
+ harmony_root = d
+ status = "not-found"
+ break
+
+ d = parent
+
+ if harmony_root is None:
+ # Extremely defensive; in practice harmony_root will be set above.
+ harmony_root = d
+
+ root_base = os.path.basename(harmony_root)
+
+ # Warning to stderr if we are not literally in a 'Harmony' directory
+ if root_base != "Harmony":
+ sys.stderr.write(
+ f"WARNING: Harmony root basename is '{root_base}', expected 'Harmony'.\n"
+ )
+
+ if meta.debug_has("print_Harmony_root"):
+ if status == "found":
+ print(f"The Harmony project root found at: {harmony_root}")
+ elif status == "different":
+ print(f"Harmony not found, but found: {harmony_root}")
+ else:
+ print("Harmony not found.")
+
+ return status, harmony_root
+
+def test_where() -> int:
+ """
+ Simple test that prints the Harmony root using the debug flag.
+ """
+ meta.debug_set("print_Harmony_root")
+ status, _root = where()
+ return 0 if status != "not-found" else 1
+
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+Harmony_where - CLI to locate the Harmony project root
+
+Usage:
+ Harmony_where
+
+Prints the status and path returned by Harmony.where().
+"""
+
+from __future__ import annotations
+
+import sys
+
+import Harmony
+
+
+def CLI(argv=None) -> int:
+ # Ignore argv; no arguments expected
+ status, Harmony_root = Harmony.where()
+
+ if status == "found":
+ print(f"Harmony project root found at: {Harmony_root}")
+ return 0
+
+ if status == "different":
+ print(f"Harmony not found, but nearest .git directory is: {Harmony_root}")
+ return 1
+
+ print("Harmony project root not found.")
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#+TITLE: skeleton_compare – Harmony skeleton comparison tool
+#+AUTHOR: Reasoning Technology
+
+* 1. Overview
+
+1.1
+~skeleton_compare~ compares a Harmony skeleton (=A=) with a derived or legacy project (=B=).
+
+1.2
+It answers:
+
+- How has B diverged from A?
+- What should be imported back into A?
+- What should be exported from A into B?
+- Which nodes are misplaced or suspicious?
+- Which nodes represent valid project-specific extensions?
+
+1.3
+The entrypoint in this project is the symlink:
+
+- =tool/skeleton_compaare=
+
+which points to:
+
+- =tool/skeleton_compare_source/CLI.py=
+
+* 2. Role in the Harmony ecosystem
+
+2.1
+Harmony defines a skeleton layout (directories, leaves, extension points).
+
+2.2
+Projects are expected to:
+
+- start from that skeleton
+- add work under approved extension points
+- keep core structure aligned over time
+
+2.3
+Reality diverges:
+
+- legacy projects that predate Harmony
+- projects with ad-hoc edits in skeleton areas
+- skeleton evolution over months or years
+
+2.4
+~skeleton_compare~ provides:
+
+- a structural comparison
+- a semantic comparison (types, topology)
+- a chronological comparison (mtimes)
+- actionable commands to re-align projects
+
+* 3. High-level behavior
+
+3.1
+Tree construction
+
+1. Build =tree_dict= for A (Harmony skeleton).
+2. Build =tree_dict= for B (other project).
+3. Attach metadata per relative path:
+
+ - =node_type= :: =directory= | =file= | =other= | =constrained=
+ - =dir_info= :: =root= | =branch= | =leaf= | =NA=
+ - =mtime= :: float seconds since epoch
+
+3.2
+Git ignore
+
+1. A simplified =.gitignore= model is applied.
+2. Some paths (e.g., =.git=) are always ignored.
+3. Only paths admitted by this model participate in comparisons.
+
+3.3
+Topology classification (relative to A)
+
+1. =in_between= :: under a directory in A, but not under any leaf in A.
+2. =below= :: under a leaf directory in A.
+3. Neither :: not under any directory known to A (ignored for most commands).
+
+3.4
+Chronological classification
+
+1. newer(B,A) :: B node has a newer mtime than A at the same path.
+2. older(B,A) :: B node has an older mtime than A at the same path.
+3. A-only :: path exists in A but not B.
+4. B-only :: path exists in B but not A.
+
+* 4. Command surface (conceptual)
+
+4.1
+~structure~
+
+1. Compares directory topology.
+2. Reports directories that:
+
+ - exist as directories in A
+ - are missing or non-directories in B
+
+3. Intended use:
+
+ - detect missing branches in projects
+ - detect structural drift
+
+4.2
+~import~
+
+1. Direction: B → A.
+2. Only considers:
+
+ - nodes in the =in_between= region of B
+ - that are new or absent in A
+
+3. Outputs:
+
+ - ~mkdir -p~ commands (when needed)
+ - ~cp --parents -a~ commands for files
+ - a comment list for nodes that cannot be handled automatically
+ (type mismatches, non-file/dir, constrained nodes)
+
+4. Intended use:
+
+ - mine “good ideas” in B that belong in the skeleton
+ - keep Harmony evolving based on real projects
+
+4.3
+~export~
+
+1. Direction: A → B.
+2. Considers:
+
+ - A-only nodes (present in A, missing in B)
+ - nodes where A’s file is newer than B’s file
+
+3. Outputs:
+
+ - ~mkdir -p~ commands for B
+ - ~cp --parents -a~ commands for files
+
+4. Intended use:
+
+ - bring B back into alignment with the current Harmony skeleton
+ - propagate skeleton fixes and improvements into projects
+
+4.4
+~suspicious~
+
+1. Reports nodes in B that are:
+
+ - inside A’s directory structure
+ - but not under any leaf directory
+
+2. Intended use:
+
+ - highlight questionable placements
+ - identify candidates for new skeleton structure
+ - catch misuse of the skeleton (work living in the “framework” layer)
+
+4.5
+~addendum~
+
+1. Reports nodes in B that are:
+
+ - under leaf directories in A
+
+2. Intended use:
+
+ - show work added at the intended extension points
+ - give a quick outline of “project-specific” content layered on Harmony
+
+4.6
+~all~
+
+1. Runs:
+
+ - =structure=
+ - =import=
+ - =export=
+ - =suspicious=
+ - =addendum=
+
+2. Intended use:
+
+ - periodic health check of a project against Harmony
+ - initial analysis when inheriting an old project
+
+* 5. Safety and behavior guarantees
+
+5.1
+No direct modification
+
+1. ~skeleton_compaare~ itself does not modify either tree.
+2. It only prints suggested shell commands.
+3. A human is expected to review and run those commands (or not).
+
+5.2
+Constrained and unknown nodes
+
+1. Some paths are “constrained”:
+
+ - object exists but metadata (e.g., ~mtime~) cannot be safely read
+ - typical for special files or broken links
+
+2. These are:
+
+ - classified as =constrained=
+ - never touched by import/export logic
+ - surfaced in “not handled automatically” lists
+
+5.3
+Robust to legacy layouts
+
+1. A and B are assumed to be non-overlapping roots.
+2. B does not have to be a clean Harmony derivative.
+3. The tool is designed to:
+
+ - tolerate missing branches
+ - tolerate ad-hoc additions
+ - still classify and report differences coherently
+
+* 6. How to run it
+
+6.1
+From inside the Harmony repo:
+
+#+begin_src sh
+cd /path/to/Harmony
+tool/skeleton_compaare help
+tool/skeleton_compaare usage
+tool/skeleton_compaare structure ../SomeProject
+tool/skeleton_compaare all ../Rabbit
+#+end_src
+
+6.2
+The CLI help (from ~doc.py~) is the canonical reference for:
+
+1. grammar and argument rules
+2. meaning of A and B
+3. exact semantics of each command
+
+This =.org= file is a conceptual overview for Harmony toolsmiths and administrators.
+
+* 7. Maintenance notes
+
+7.1
+Core modules
+
+1. =skeleton_compare_source/skeleton.py=
+ - tree construction
+ - topology classification
+ - “newer/older” logic
+ - in-between / below partitioning
+
+2. =skeleton_compare_source/command.py=
+ - high-level command semantics
+ - import/export planning and printing
+
+3. =skeleton_compare_source/CLI.py=
+ - argument classification
+ - environment checks
+ - dispatch to command handlers
+
+7.2
+Change discipline
+
+1. CLI behavior and text should be updated in:
+
+ - =doc.py= (help/usage text)
+ - this =.org= file (conceptual intent)
+
+2. Any behavioral change that affects:
+
+ - classification rules
+ - import/export semantics
+ - constrained handling
+
+ should be reflected here in section 3 or 4.
+
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+command.py - high-level dispatch for <has_other> Harmony check commands
+
+Commands (semantics):
+
+ structure:
+ - Differences in directory structure: directories present in A but
+ not present as directories in B.
+
+ import:
+ - Shell copy commands to copy:
+ * in-between nodes in B that are newer than A (same relative path), or
+ * in-between nodes in B that do not exist in A at all.
+ Direction: B -> A
+ Also emits:
+ * a mkdir list (directories to create in A)
+ * an "other" list for type mismatches / non-file/dir nodes.
+
+ export:
+ - Shell copy commands to copy:
+ * nodes in A that are newer than B, and
+ * nodes in A that do not exist in B.
+ Direction: A -> B
+ Also emits:
+ * a mkdir list (directories to create in B)
+ * an "other" list for type mismatches / non-file/dir nodes.
+
+ suspicious:
+ - Nodes in B that fall "in between" the Harmony skeleton topology:
+ under some A directory, but not under any A leaf directory.
+ (tree_dict_in_between_and_below(A,B).in_between)
+
+ addendum:
+ - Nodes in B that fall "below" Harmony leaf directories:
+ added work in appropriate extension locations.
+ (tree_dict_in_between_and_below(A,B).below)
+
+ all:
+ - Runs structure, import, export, suspicious, and addendum.
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any, Dict, List, Tuple
+
+import skeleton
+
+TreeDict = Dict[str, Dict[str, Any]]
+
+
+def shell_quote(
+ s: str
+) -> str:
+ """
+ Minimal single-quote shell quoting.
+ """
+ return "'" + s.replace("'", "'\"'\"'") + "'"
+
+
+def _print_header(
+ title: str
+) -> None:
+ print()
+ print(f"== {title} ==")
+
+
+# ----------------------------------------------------------------------
+# structure: directories in A that are missing / non-directories in B
+# ----------------------------------------------------------------------
+def cmd_structure(
+ A: TreeDict
+ ,B: TreeDict
+) -> int:
+ """
+ structure: differences in directory structure, directories in A - B.
+
+ We include any path where:
+ - A[path].node_type == 'directory', and
+ - either path not in B, or B[path].node_type != 'directory'.
+ """
+ structural: TreeDict = {}
+
+ for path, info_A in A.items():
+ if info_A.get("node_type") != "directory":
+ continue
+
+ info_B = B.get(path)
+ if info_B is None or info_B.get("node_type") != "directory":
+ structural[path] = info_A
+
+ if not structural:
+ _print_header("structure")
+ print("No structural directory differences (A - B).")
+ return 0
+
+ _print_header("structure: directories in A not in B")
+ skeleton.tree_dict_print(structural)
+ return 0
+
+
+# ----------------------------------------------------------------------
+# import: B -> A (mkdir, cp, and "other" list), using in_between_newer
+# ----------------------------------------------------------------------
+
+
+def build_import_commands(
+ A_tree: TreeDict
+ ,B_tree: TreeDict
+ ,A_root: str
+ ,B_root: str
+) -> Tuple[List[str], List[str], List[str]]:
+ """
+ Compute shell commands to update A from B.
+
+ Returns:
+ (mkdir_cmds, cp_cmds, other_list)
+
+ Semantics:
+
+ mkdir_cmds:
+ - Directories that are directories in B, but are missing in A.
+ - We DO NOT auto-resolve type mismatches (e.g. B=directory,
+ A=file); those go into other_list.
+
+ cp_cmds:
+ - Files where:
+ * the path does not exist in A, OR
+ * the node in A is not a file, OR
+ * the B copy is newer than A (mtime comparison).
+ - However, if A has a non-file at that path, we treat it as a
+ type mismatch and add that path to other_list instead of
+ emitting a cp command.
+
+ other_list:
+ - Human-readable notes for:
+ * type mismatches between A and B, and
+ * nodes in B that are neither 'file' nor 'directory'.
+ """
+ mkdir_cmds: List[str] = []
+ cp_cmds: List[str] = []
+ other_list: List[str] = []
+
+ for rel_path, b_info in B_tree.items():
+ b_type = b_info.get("node_type")
+ rel_display = rel_path if rel_path else "."
+
+ a_info = A_tree.get(rel_path)
+ a_type = a_info.get("node_type") if a_info is not None else "MISSING"
+
+ # Case 1: B node is neither file nor directory -> other_list
+ if b_type not in ("file", "directory"):
+ other_list.append(
+ f"{rel_display}: A={a_type}, B={b_type}"
+ )
+ continue
+
+ # Case 2: B directory
+ if b_type == "directory":
+ if a_info is None:
+ # Missing in A: copy the directory recursively.
+ src = os.path.join(B_root, rel_path) if rel_path else B_root
+ # The destination should be the parent directory in A.
+ # os.path.join(A_root, rel_path) gives the full path to the new directory in A.
+ # os.path.dirname of that gives the parent directory.
+ dst = os.path.dirname(os.path.join(A_root, rel_path))
+ mkdir_cmds.append(
+ f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
+ )
+ else:
+ # Exists in A: must also be a directory to be "structurally OK"
+ if a_type != "directory":
+ # Type mismatch: do not mkdir, just report
+ other_list.append(
+ f"{rel_display}: A={a_type}, B=directory"
+ )
+ continue
+
+ # Case 3: B file
+ # Decide whether to copy B -> A, or report conflict.
+ if a_info is None:
+ # B-only file
+ src = os.path.join(B_root, rel_path) if rel_path else B_root
+ # Destination is the parent directory in A, with a trailing slash
+ dst = os.path.dirname(os.path.join(A_root, rel_path))
+ cp_cmds.append(
+ f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
+ )
+ continue
+
+ # A has something at this path
+ if a_type != "file":
+ # Type mismatch (e.g. A=directory, B=file, or A=other)
+ other_list.append(
+ f"{rel_display}: A={a_type}, B=file"
+ )
+ continue
+
+ # Both files: compare mtime
+ a_mtime = a_info.get("mtime")
+ b_mtime = b_info.get("mtime")
+
+ if isinstance(a_mtime, (int, float)) and isinstance(b_mtime, (int, float)):
+ if b_mtime > a_mtime:
+ src = os.path.join(B_root, rel_path) if rel_path else B_root
+ # Destination is the parent directory in A, with a trailing slash
+ dst = os.path.dirname(os.path.join(A_root, rel_path))
+ cp_cmds.append(
+ f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
+ )
+
+ return mkdir_cmds, cp_cmds, other_list
+
+def cmd_import(
+ A_tree: TreeDict
+ ,B_tree: TreeDict
+ ,A_root: str
+ ,B_root: str
+) -> int:
+ """
+ import: update the skeleton (A) from the project (B),
+ using only in_between_newer nodes.
+ """
+ inb_newer = skeleton.in_between_newer(A_tree, B_tree)
+
+ mkdir_cmds, cp_cmds, other_list = build_import_commands(
+ A_tree
+ ,inb_newer
+ ,A_root
+ ,B_root
+ )
+
+ print("== import: copy from B -> A (in-between newer only) ==")
+ print(f"# A root: {A_root}")
+ print(f"# B root: {B_root}")
+ print("# Only considering in-between files that are new or absent in A.")
+ print("#")
+
+ print("# Directories to copy from B -> A (cp -a):")
+ if mkdir_cmds:
+ for line in mkdir_cmds:
+ print(line)
+ else:
+ print("# (none)")
+ print("#")
+
+ print("# Files to copy from B -> A (cp -a):")
+ if cp_cmds:
+ for line in cp_cmds:
+ print(line)
+ else:
+ print("# (none)")
+ print("#")
+
+ print("# Nodes NOT handled automatically (type mismatches / non-file/dir):")
+ if other_list:
+ for rel in other_list:
+ print(f"# {rel}")
+ else:
+ print("# (none)")
+
+ return 0
+
+
+# ----------------------------------------------------------------------
+# export: A -> B (mkdir, cp, and "other" list)
+# ----------------------------------------------------------------------
+def build_export_commands(
+ A_tree: TreeDict
+ ,B_tree: TreeDict
+ ,A_root: str
+ ,B_root: str
+) -> Tuple[List[str], List[str], List[str]]:
+ """
+ Compute shell commands to update B from A.
+
+ Returns:
+ (mkdir_cmds, cp_cmds, other_list)
+
+ Semantics:
+
+ mkdir_cmds:
+ - Directories that are directories in A, but are missing in B.
+ - Type mismatches go into other_list.
+
+ cp_cmds:
+ - Files where:
+ * the path does not exist in B, OR
+ * the node in B is not a file, OR
+ * the A copy is newer than B (mtime comparison).
+ - If B has a non-file while A has a file, treat as type mismatch.
+
+ other_list:
+ - Human-readable notes for:
+ * type mismatches between A and B, and
+ * nodes in A that are neither 'file' nor 'directory'.
+ """
+ mkdir_cmds: List[str] = []
+ cp_cmds: List[str] = []
+ other_list: List[str] = []
+
+ # Sort keys to ensure parent directories are processed before their children.
+ sorted_paths = sorted(A_tree.keys(), key=len)
+ included_dirs: Set[str] = set()
+
+ for rel_path in sorted_paths:
+ a_info = A_tree[rel_path]
+ a_type = a_info.get("node_type")
+ rel_display = rel_path if rel_path else "."
+
+ b_info = B_tree.get(rel_path)
+ b_type = b_info.get("node_type") if b_info is not None else "MISSING"
+
+ # Case 1: A node is neither file nor directory -> other_list
+ if a_type not in ("file", "directory"):
+ other_list.append(
+ f"{rel_display}: A={a_type}, B={b_type}"
+ )
+ continue
+
+ # Check if this path is a child of an already included directory
+ is_child_of_included_dir = False
+ for d in included_dirs:
+ if rel_path.startswith(d + os.sep):
+ is_child_of_included_dir = True
+ break
+
+ if is_child_of_included_dir:
+ continue
+
+ # Case 2: A directory
+ if a_type == "directory":
+ if b_info is None:
+ # Missing in B: copy the directory recursively.
+ src = os.path.join(A_root, rel_path) if rel_path else A_root
+ # The destination should be the parent directory in B.
+ dst = os.path.dirname(os.path.join(B_root, rel_path))
+ mkdir_cmds.append(
+ f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
+ )
+ included_dirs.add(rel_path)
+ else:
+ # Exists in B: must also be directory
+ if b_type != "directory":
+ other_list.append(
+ f"{rel_display}: A=directory, B={b_type}"
+ )
+ continue
+
+ # Case 3: A file
+ if b_info is None:
+ # A-only file
+ src = os.path.join(A_root, rel_path) if rel_path else A_root
+ # Destination is the parent directory in B, with a trailing slash
+ dst = os.path.dirname(os.path.join(B_root, rel_path))
+ cp_cmds.append(
+ f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
+ )
+ continue
+
+ if b_type != "file":
+ other_list.append(
+ f"{rel_display}: A=file, B={b_type}"
+ )
+ continue
+
+ # Both files: compare mtime
+ a_mtime = a_info.get("mtime")
+ b_mtime = b_info.get("mtime")
+
+ if isinstance(a_mtime, (int, float)) and isinstance(b_mtime, (int, float)):
+ if a_mtime > b_mtime:
+ src = os.path.join(A_root, rel_path) if rel_path else A_root
+ # Destination is the parent directory in B, with a trailing slash
+ dst = os.path.dirname(os.path.join(B_root, rel_path))
+ cp_cmds.append(
+ f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
+ )
+
+ return mkdir_cmds, cp_cmds, other_list
+
+
+def cmd_export(
+ A_tree: TreeDict
+ ,B_tree: TreeDict
+ ,A_root: str
+ ,B_root: str
+) -> int:
+ """
+ export: show directory creation and copy commands A -> B.
+ """
+ mkdir_cmds, cp_cmds, other_list = build_export_commands(
+ A_tree
+ ,B_tree
+ ,A_root
+ ,B_root
+ )
+
+ print("== export: copy from A -> B ==")
+ print(f"# A root: {A_root}")
+ print(f"# B root: {B_root}")
+ print("#")
+
+ print("# Directories to copy from A -> B (cp -a):")
+ if mkdir_cmds:
+ for line in mkdir_cmds:
+ print(line)
+ else:
+ print("# (none)")
+ print("#")
+
+ print("# Files to copy from A -> B (cp -a):")
+ if cp_cmds:
+ for line in cp_cmds:
+ print(line)
+ else:
+ print("# (none)")
+ print("#")
+
+ print("# Nodes NOT handled automatically (type mismatches / non-file/dir):")
+ if other_list:
+ for rel in other_list:
+ print(f"# {rel}")
+ else:
+ print("# (none)")
+
+ return 0
+
+
+# ----------------------------------------------------------------------
+# suspicious / addendum via in_between_and_below
+# ----------------------------------------------------------------------
+def cmd_suspicious(
+ A: TreeDict
+ ,B: TreeDict
+) -> int:
+ """
+ suspicious: nodes in B that fall 'in between' the Harmony skeleton,
+ not under leaf directories.
+ """
+ in_between, _below = skeleton.tree_dict_in_between_and_below(A, B)
+
+ _print_header("suspicious: nodes in-between Harmony leaves")
+
+ if not in_between:
+ print("No suspicious nodes found in B (relative to A).")
+ return 0
+
+ skeleton.tree_dict_print(in_between)
+ return 0
+
+
+def cmd_addendum(
+ A: TreeDict
+ ,B: TreeDict
+) -> int:
+ """
+ addendum: nodes in B that fall 'below' Harmony leaf directories.
+ """
+ _in_between, below = skeleton.tree_dict_in_between_and_below(A, B)
+
+ _print_header("addendum: nodes added under Harmony leaves")
+
+ if not below:
+ print("No addendum nodes found in B (relative to A).")
+ return 0
+
+ skeleton.tree_dict_print(below)
+ return 0
+
+
+# ----------------------------------------------------------------------
+# Top-level dispatcher
+# ----------------------------------------------------------------------
+def dispatch(
+ has_other_list: List[str]
+ ,A: TreeDict
+ ,B: TreeDict
+ ,A_root: str
+ ,B_root: str
+) -> int:
+ """
+ Dispatch <has_other> commands.
+
+ has_other_list:
+ List of command tokens (subset of:
+ 'structure', 'import', 'export', 'suspicious', 'addendum', 'all').
+
+ A, B:
+ tree_dicts for Harmony skeleton (A) and <other> project (B).
+
+ A_root, B_root:
+ Root paths corresponding to A and B (for copy commands).
+ """
+ cmds = set(has_other_list)
+
+ if "all" in cmds:
+ cmds.update([
+ "structure"
+ ,"import"
+ ,"export"
+ ,"suspicious"
+ ,"addendum"
+ ])
+
+ ordered = [
+ "structure"
+ ,"import"
+ ,"export"
+ ,"suspicious"
+ ,"addendum"
+ ]
+
+ status = 0
+
+ for name in ordered:
+ if name not in cmds:
+ continue
+
+ if name == "structure":
+ rc = cmd_structure(A, B)
+ elif name == "import":
+ rc = cmd_import(A, B, A_root, B_root)
+ elif name == "export":
+ rc = cmd_export(A, B, A_root, B_root)
+ elif name == "suspicious":
+ rc = cmd_suspicious(A, B)
+ elif name == "addendum":
+ rc = cmd_addendum(A, B)
+ else:
+ rc = 0
+
+ if rc != 0:
+ status = rc
+
+ return status
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+doc.py - usage and help text for the Harmony 'check' tool
+
+Grammar (informal):
+
+ <prog> <command>* [<other>]
+
+ <command> :: <help> | <no_other> | <has_other>
+
+ <help> :: version | help | usage
+ <no_other> :: environment
+ <has_other> :: structure | import | export | suspicious | addendum | all
+"""
+
+from __future__ import annotations
+
+import meta
+import os
+import sys
+from typing import TextIO
+
+
+def prog_name() -> str:
+ """
+ Return the program name as invoked by the user.
+
+ Typically:
+ - basename(sys.argv[0]) when running from the shell.
+ - Falls back to 'check' if argv[0] is empty.
+ """
+ raw = sys.argv[0] if sys.argv and sys.argv[0] else "check"
+ base = os.path.basename(raw) or raw
+ return base
+
+
+def _usage_text(prog: str) -> str:
+ return f"""\
+Usage:
+ {prog} <command>* [<other>]
+
+Where:
+ <command> :: <help> | <no_other> | <has_other>
+
+ <help> :: version | help | usage
+ <no_other> :: environment
+ <has_other> :: structure | import | export | suspicious | addendum | all
+"""
+
+def _help_text(prog: str) -> str:
+ return f"""\
+{prog} - Harmony skeleton integrity and metadata checker
+
+Syntax:
+ {prog} <command>* [<other>]
+
+Where:
+ <other> :: path
+ <command> :: <help> | <no_other> | <has_other>
+
+ <help> :: version | help | usage
+ <no_other> :: environment
+ <has_other> :: structure | import | export | suspicious | addendum | all
+
+Argument rules (informal):
+ 1. <help> commands are processed first, and then the program returns.
+ Hence if any help commands are present, the remaining commands
+ are ignored.
+
+ 2. We assume {prog} is run within the Harmony skeleton, or a skeleton
+ derived directly from it. This is the 'default skeleton', or simply 'A'.
+
+ 3. The <other> path is the directory of a project that is assumed to
+ be built upon the default skeleton. This second project root is
+ called 'B'.
+
+ 4. If none of the commands require an <other> path, then <other>
+ must not be given. If at least one command requires <other>, then
+ <other> is required. Commands that require a path are called
+ <has_other> commands.
+
+ 5. Implementation detail:
+ All arguments except the final one are interpreted strictly as
+ command tokens. If any of those are <has_other>, the final argument
+ is taken as <other>. If none of the earlier tokens are <has_other>,
+ the final argument is also treated as a command token.
+
+Roots:
+ A = Skeleton project root (auto-detected). Usually the Harmony skeleton.
+ B = <other> project root (supplied when required).
+
+{prog} compares A with B. Differences may come from:
+ - edits to the skeleton itself,
+ - edits to skeleton files inside B,
+ - or new files/directories added to B.
+Conceptually, A and B are any two non-overlapping directory trees.
+
+Command semantics:
+ structure
+ - Report directory-structure differences:
+ directories present in A that are missing in B or not
+ directories in B.
+ - Output: table of such directories.
+
+ import
+ - Update A from B using only "in-between newer" files:
+ * files in B that lie in the 'in-between' region relative to A, and
+ * are newer than A or absent from A.
+ - Also emits:
+ * directories to create in A,
+ * files to copy (B -> A),
+ * nodes that cannot be handled automatically (type mismatches,
+ constrained nodes, non-file/dir nodes).
+ - Direction: B -> A
+
+ export
+ - Update B from A:
+ * files in A newer than B at the same path,
+ * files present in A but missing in B.
+ - Also emits:
+ * directories to create in B,
+ * files to copy (A -> B),
+ * nodes that cannot be handled automatically.
+ - Direction: A -> B
+
+ suspicious
+ - Report B nodes that lie "in-between" Harmony leaves:
+ under a directory from A, but not under any leaf directory of A.
+ - Indicates questionable placements or missing skeleton structure.
+
+ addendum
+ - Report B nodes located "below" Harmony leaf directories:
+ project-specific additions placed in proper extension points.
+
+ all
+ - Run: structure, import, export, suspicious, addendum (in that order).
+
+Notes:
+ - tree_dict traversal respects a simplified .gitignore model plus
+ always-ignored patterns (e.g. '.git').
+ - Timestamps are formatted via the Z helper in UTC (ISO 8601).
+"""
+
+def print_usage(
+ stream: TextIO | None = None
+) -> None:
+ """
+ Print the usage text to the given stream (default: sys.stdout),
+ using the actual program name as invoked.
+ """
+ if stream is None:
+ stream = sys.stdout
+
+ text = _usage_text(prog_name())
+ stream.write(text)
+ if not text.endswith("\n"):
+ stream.write("\n")
+
+
+def print_help(
+ stream: TextIO | None = None
+) -> None:
+ """
+ Print the help text to the given stream (default: sys.stdout),
+ using the actual program name as invoked.
+ """
+ if stream is None:
+ stream = sys.stdout
+
+ utext = _usage_text(prog_name())
+ htext = _help_text(prog_name())
+
+ stream.write(utext)
+ if not utext.endswith("\n"):
+ stream.write("\n")
+
+ stream.write("\n")
+ stream.write(htext)
+ if not htext.endswith("\n"):
+ stream.write("\n")
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+in_between_and_below - CLI test driver for skeleton.tree_dict_in_between_and_below(A, B)
+
+Usage:
+ in_between_and_below <A_root> <B_root>
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from typing import Sequence
+
+import meta
+import skeleton
+
+
+def CLI(argv: Sequence[str] | None = None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+
+ prog = os.path.basename(sys.argv[0]) if sys.argv else "in_between_and_below"
+
+ if len(argv) != 2 or argv[0] in ("-h", "--help"):
+ print(f"Usage: {prog} <A_root> <B_root>")
+ return 1
+
+ A_root = argv[0]
+ B_root = argv[1]
+
+ if not os.path.isdir(A_root):
+ print(f"{prog}: {A_root}: not a directory")
+ return 2
+
+ if not os.path.isdir(B_root):
+ print(f"{prog}: {B_root}: not a directory")
+ return 3
+
+ A = skeleton.tree_dict_make(A_root, None)
+ B = skeleton.tree_dict_make(B_root, None)
+
+ meta.debug_set("tree_dict_in_between_and_below")
+
+ _result = skeleton.tree_dict_in_between_and_below(A, B)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+load_command_module.py - locate and import Python command modules from $PATH
+
+Behavior:
+ 1. Search $PATH for an executable with the given command name.
+ 2. Prefer a path containing '/incommon/'.
+ 3. If only /usr/bin/<command> is found, raise an error saying we were
+ looking for the incommon version.
+ 4. Import the chosen script as a Python module, even if it has no .py
+ extension, by forcing a SourceFileLoader.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import os
+from importlib.machinery import SourceFileLoader
+from types import ModuleType
+from typing import List
+
+
+def _find_command_candidates(command_name: str) -> List[str]:
+ """
+ Return a list of absolute paths to executables named `command_name`
+ found on $PATH.
+ """
+ paths: list[str] = []
+
+ path_env = os.environ.get("PATH", "")
+ for dir_path in path_env.split(os.pathsep):
+ if not dir_path:
+ continue
+ candidate = os.path.join(dir_path, command_name)
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ paths.append(os.path.realpath(candidate))
+
+ return paths
+
+
+def load_command_module(command_name: str) -> ModuleType:
+ """
+ Locate an executable named `command_name` on $PATH and load it
+ as a Python module.
+
+ Selection policy:
+ 1. Prefer any path containing '/incommon/'.
+ 2. If only /usr/bin/<command_name> candidates exist, raise an error
+ saying we were looking for the incommon version.
+ 3. If no candidate is found, raise an error.
+
+ Implementation detail:
+ Because the incommon command may lack a .py suffix, we explicitly
+ construct a SourceFileLoader rather than relying on the default
+ extension-based loader resolution.
+ """
+ candidates = _find_command_candidates(command_name)
+
+ incommon_candidates = [
+ p
+ for p in candidates
+ if "/incommon/" in p
+ ]
+
+ usrbin_candidates = [
+ p
+ for p in candidates
+ if p.startswith("/usr/bin/")
+ ]
+
+ if incommon_candidates:
+ target = incommon_candidates[0]
+ elif usrbin_candidates:
+ raise RuntimeError(
+ f"Found /usr/bin/{command_name}, but expected the incommon Python "
+ f"{command_name} module on PATH."
+ )
+ else:
+ raise RuntimeError(
+ f"Could not find an incommon '{command_name}' module on PATH."
+ )
+
+ module_name = f"rt_incommon_{command_name}"
+
+ loader = SourceFileLoader(
+ module_name
+ ,target
+ )
+ spec = importlib.util.spec_from_loader(
+ module_name
+ ,loader
+ )
+ if spec is None:
+ raise RuntimeError(f"Failed to create spec for {command_name} from {target}")
+
+ module = importlib.util.module_from_spec(spec)
+ # spec.loader is the SourceFileLoader we just created
+ assert spec.loader is not None
+ spec.loader.exec_module(module)
+
+ return module
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+skeleton_test - build and print the Harmony tree_dict
+
+Usage:
+ skeleton_test
+
+Behavior:
+ 1. Locate the Harmony project root via Harmony.where().
+ 2. Enable 'tree_dict_print' debug flag.
+ 3. Call skeleton.tree_dict_make(Harmony_root, None).
+
+The skeleton.tree_dict_make() function is expected to call
+tree_dict_print() when the 'tree_dict_print' debug flag is set.
+"""
+
+from __future__ import annotations
+
+import sys
+
+import Harmony
+import meta
+import skeleton
+
+
+def CLI(argv=None) -> int:
+ # No arguments expected
+ status, Harmony_root = Harmony.where()
+
+ if status == "not-found":
+ print("Harmony project not found; cannot build tree_dict.")
+ return 1
+
+ if status == "different":
+ print("Warning: Harmony not found, using nearest .git directory for tree_dict.")
+
+ # Enable printing inside tree_dict_make
+ meta.debug_set("tree_dict_print")
+
+ _tree = skeleton.tree_dict_make(Harmony_root, None)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+meta.py - thin wrappers around command modules
+
+Current responsibilities:
+ 1. Load the incommon 'printenv' command module (no .py extension)
+ using load_command_module.load_command_module().
+ 2. Expose printenv() here, calling the imported printenv() work
+ function with default arguments (equivalent to running without
+ any CLI arguments).
+ 3. Provide a simple version printer for this meta module.
+ 4. Provide a small debug tag API (set/clear/has).
+"""
+
+from __future__ import annotations
+
+import datetime
+from load_command_module import load_command_module
+
+
+# Load the incommon printenv module once at import time
+_PRINTENV_MODULE = load_command_module("printenv")
+_Z_MODULE = load_command_module("Z")
+
+
+# Meta module version
+_major = 1
+_minor = 7
+def version_print() -> None:
+ """
+ Print the meta module version as MAJOR.MINOR.
+ """
+ print(f"{_major}.{_minor}")
+
+
+# Debug tag set and helpers
+_debug = set([
+])
+
+
+def debug_set(tag: str) -> None:
+ """
+ Add a debug tag to the meta debug set.
+ """
+ _debug.add(tag)
+
+
+def debug_clear(tag: str) -> None:
+ """
+ Remove a debug tag from the meta debug set, if present.
+ """
+ _debug.discard(tag)
+
+
+def debug_has(tag: str) -> bool:
+ """
+ Return True if the given debug tag is present.
+ """
+ return tag in _debug
+
+
+# Touch the default tag once so static checkers do not complain about
+# unused helpers when imported purely for side-effects.
+debug_has("Command")
+
+
+def printenv() -> int:
+ """
+ Call the imported printenv() work function with default arguments:
+ - no null termination
+ - no newline quoting
+ - no specific names (print full environment)
+ - prog name 'printenv'
+ """
+ return _PRINTENV_MODULE.printenv(
+ False # null_terminate
+ ,False # quote_newlines
+ ,[] # names
+ ,"printenv"
+ )
+
+
+def z_format_mtime(
+ mtime: float
+) -> str:
+ """
+ Format a POSIX mtime (seconds since epoch, UTC) using the Z module.
+
+ Uses Z.ISO8601_FORMAT and Z.make_timestamp(dt=...).
+ """
+ dt = datetime.datetime.fromtimestamp(mtime, datetime.timezone.utc)
+ return _Z_MODULE.make_timestamp(
+ fmt=_Z_MODULE.ISO8601_FORMAT
+ ,dt=dt
+ )
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+newer - CLI test driver for skeleton.tree_dict_newer(A, B)
+
+Usage:
+ newer <A_root> <B_root>
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from typing import Sequence
+
+import meta
+import skeleton
+
+
+def CLI(argv: Sequence[str] | None = None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+
+ prog = os.path.basename(sys.argv[0]) if sys.argv else "newer"
+
+ if len(argv) != 2 or argv[0] in ("-h", "--help"):
+ print(f"Usage: {prog} <A_root> <B_root>")
+ return 1
+
+ A_root = argv[0]
+ B_root = argv[1]
+
+ if not os.path.isdir(A_root):
+ print(f"{prog}: {A_root}: not a directory")
+ return 2
+
+ if not os.path.isdir(B_root):
+ print(f"{prog}: {B_root}: not a directory")
+ return 3
+
+ A = skeleton.tree_dict_make(A_root, None)
+ B = skeleton.tree_dict_make(B_root, None)
+
+ meta.debug_set("tree_dict_newer")
+
+ _result = skeleton.tree_dict_newer(A, B)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+older - CLI test driver for skeleton.tree_dict_older(A, B)
+
+Usage:
+ older <A_root> <B_root>
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from typing import Sequence
+
+import meta
+import skeleton
+
+
+def CLI(argv: Sequence[str] | None = None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+
+ prog = os.path.basename(sys.argv[0]) if sys.argv else "older"
+
+ if len(argv) != 2 or argv[0] in ("-h", "--help"):
+ print(f"Usage: {prog} <A_root> <B_root>")
+ return 1
+
+ A_root = argv[0]
+ B_root = argv[1]
+
+ if not os.path.isdir(A_root):
+ print(f"{prog}: {A_root}: not a directory")
+ return 2
+
+ if not os.path.isdir(B_root):
+ print(f"{prog}: {B_root}: not a directory")
+ return 3
+
+ A = skeleton.tree_dict_make(A_root, None)
+ B = skeleton.tree_dict_make(B_root, None)
+
+ meta.debug_set("tree_dict_older")
+
+ _result = skeleton.tree_dict_older(A, B)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+skeleton.py - helpers for working with the Harmony skeleton tree
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from typing import Any, Callable, Dict, List, Set
+
+import meta
+from GitIgnore import GitIgnore
+import Harmony
+
+TreeDict = Dict[str, Dict[str, Any]]
+
+# tree_dict_make / tree_dict_print
+#
+# Build a dictionary describing a project tree, respecting GitIgnore.
+#
+# tree_dict_make(<path>, <checksum_fn>) -> tree_dict
+#
+# <checksum_fn>(<abs_path>) -> bignum | None
+#
+# Keys of tree_dict:
+# - Relative paths from <path>; the root itself is stored under "".
+#
+# Values are dicts with:
+# 1. 'mtime' : last modification time (float seconds) or None
+# 2. 'node_type' : 'file', 'directory', 'other', or 'constrained'
+# 3. 'dir_info' : 'NA', 'leaf', 'branch', 'root'
+# 4. 'checksum' : present only for file nodes when checksum_fn is
+# not None
+#
+# Traversal:
+# - Directories whose relative path GitIgnore.check() marks as
+# 'Ignore' are included in tree_dict but not traversed further.
+def tree_dict_make(
+ path: str
+ ,checksum_fn: Callable[[str], int] | None
+) -> Dict[str, Dict[str, Any]]:
+ """
+ Build a tree_dict for the subtree rooted at <path>, respecting GitIgnore.
+
+ Semantics (current):
+ * Any path (directory or file) for which GitIgnore.check(<rel_path>)
+ returns 'Ignore' is completely omitted from the tree_dict.
+ * The root directory ('') is always included.
+ * Directory dir_info:
+ - 'root' for the root
+ - 'branch' for directories that have child directories
+ (after GitIgnore filtering)
+ - 'leaf' for directories with no child directories
+ * Non-directory dir_info:
+ - 'NA'
+ * Symlinks are classified as file/directory/other based on what
+ they point to, if accessible.
+ * If any filesystem access needed for classification/mtime raises,
+ the node is recorded as node_type='constrained', dir_info='NA',
+ mtime=None, and we do not attempt checksum.
+ """
+ root = os.path.abspath(path)
+ gi = GitIgnore(root)
+
+ tree_dict: Dict[str, Dict[str, Any]] = {}
+
+ for dirpath, dirnames, filenames in os.walk(root, topdown=True):
+ rel_dir = os.path.relpath(dirpath, root)
+ if rel_dir == ".":
+ rel_dir = ""
+
+ # Skip ignored directories (except the root).
+ if rel_dir != "" and gi.check(rel_dir) == "Ignore":
+ dirnames[:] = []
+ continue
+
+ # Filter child directories by GitIgnore so dir_info reflects
+ # only directories we will actually traverse.
+ kept_dirnames: List[str] = []
+ for dn in list(dirnames):
+ child_rel = dn if rel_dir == "" else os.path.join(rel_dir, dn)
+ if gi.check(child_rel) == "Ignore":
+ dirnames.remove(dn)
+ else:
+ kept_dirnames.append(dn)
+
+ # Record the directory node itself
+ dir_abs = dirpath
+ try:
+ dir_mtime = os.path.getmtime(dir_abs)
+ dir_node_type = "directory"
+ if rel_dir == "":
+ dir_info = "root"
+ elif kept_dirnames:
+ dir_info = "branch"
+ else:
+ dir_info = "leaf"
+ except OSError:
+ # Could not stat the directory: treat as constrained.
+ dir_mtime = None
+ dir_node_type = "constrained"
+ dir_info = "NA"
+
+ tree_dict[rel_dir] = {
+ "mtime": dir_mtime
+ ,"node_type": dir_node_type
+ ,"dir_info": dir_info
+ }
+
+ # For non-ignored directories, record files within
+ for name in filenames:
+ abs_path = os.path.join(dirpath, name)
+ if rel_dir == "":
+ rel_path = name
+ else:
+ rel_path = os.path.join(rel_dir, name)
+
+ if gi.check(rel_path) == "Ignore":
+ continue
+
+ # Wrap classification + mtime in one try/except so any failure
+ # marks the node as constrained.
+ try:
+ if os.path.islink(abs_path):
+ # Symlink: classify by target if possible
+ if os.path.isdir(abs_path):
+ node_type = "directory"
+ dir_info_f = "branch"
+ elif os.path.isfile(abs_path):
+ node_type = "file"
+ dir_info_f = "NA"
+ else:
+ node_type = "other"
+ dir_info_f = "NA"
+ mtime = os.path.getmtime(abs_path)
+ else:
+ # Normal node
+ if os.path.isfile(abs_path):
+ node_type = "file"
+ dir_info_f = "NA"
+ elif os.path.isdir(abs_path):
+ node_type = "directory"
+ dir_info_f = "branch"
+ else:
+ node_type = "other"
+ dir_info_f = "NA"
+ mtime = os.path.getmtime(abs_path)
+ except OSError:
+ # Anything that blows up during classification/stat becomes
+ # constrained; we do not attempt checksum for these.
+ node_type = "constrained"
+ dir_info_f = "NA"
+ mtime = None
+
+ info: Dict[str, Any] = {
+ "mtime": mtime
+ ,"node_type": node_type
+ ,"dir_info": dir_info_f
+ }
+
+ if node_type == "file" and checksum_fn is not None and isinstance(mtime, (int, float)):
+ info["checksum"] = checksum_fn(abs_path)
+
+ tree_dict[rel_path] = info
+
+ if meta.debug_has("tree_dict_print"):
+ tree_dict_print(tree_dict)
+
+ return tree_dict
+
+def tree_dict_print(
+ tree_dict: Dict[str, Dict[str, Any]]
+) -> None:
+ """
+ Pretty-print a tree_dict produced by tree_dict_make() in fixed-width columns:
+
+ [type] [dir] [mtime] [checksum?] [relative path]
+
+ Only the values are printed in each column (no 'field=' prefixes).
+ mtime is formatted via the Z module for human readability.
+ """
+ entries: List[tuple[str, str, str, str, str]] = []
+ has_checksum = False
+
+ for rel_path in sorted(tree_dict.keys()):
+ info = tree_dict[rel_path]
+ display_path = rel_path if rel_path != "" else "."
+
+ type_val = str(info.get("node_type", ""))
+ dir_val = str(info.get("dir_info", ""))
+
+ raw_mtime = info.get("mtime")
+ if isinstance(raw_mtime, (int, float)):
+ mtime_val = meta.z_format_mtime(raw_mtime)
+ else:
+ mtime_val = str(raw_mtime)
+
+ if "checksum" in info:
+ checksum_val = str(info["checksum"])
+ has_checksum = True
+ else:
+ checksum_val = ""
+
+ entries.append((
+ type_val
+ ,dir_val
+ ,mtime_val
+ ,checksum_val
+ ,display_path
+ ))
+
+ # Compute column widths
+ type_w = 0
+ dir_w = 0
+ mtime_w = 0
+ checksum_w = 0
+
+ for type_val, dir_val, mtime_val, checksum_val, _ in entries:
+ if len(type_val) > type_w:
+ type_w = len(type_val)
+ if len(dir_val) > dir_w:
+ dir_w = len(dir_val)
+ if len(mtime_val) > mtime_w:
+ mtime_w = len(mtime_val)
+ if has_checksum and len(checksum_val) > checksum_w:
+ checksum_w = len(checksum_val)
+
+ print("Tree dictionary contents:")
+ for type_val, dir_val, mtime_val, checksum_val, display_path in entries:
+ line = " "
+ line += type_val.ljust(type_w)
+ line += " "
+ line += dir_val.ljust(dir_w)
+ line += " "
+ line += mtime_val.ljust(mtime_w)
+
+ if has_checksum:
+ line += " "
+ line += checksum_val.ljust(checksum_w)
+
+ line += " "
+ line += display_path
+
+ print(line)
+
+
+def tree_dict_A_minus_B(
+ A: Dict[str, Dict[str, Any]]
+ ,B: Dict[str, Dict[str, Any]]
+) -> Dict[str, Dict[str, Any]]:
+ """
+ Compute the set difference of two tree_dicts at the key level:
+
+ Result = A \\ B
+
+ That is, return a new tree_dict containing only those entries whose
+ keys are present in A but NOT present in B.
+ """
+ result: Dict[str, Dict[str, Any]] = {}
+
+ B_keys = set(B.keys())
+
+ for key, info in A.items():
+ if key not in B_keys:
+ result[key] = info
+
+ if meta.debug_has("tree_dict_A_minus_B"):
+ tree_dict_print(result)
+
+ return result
+
+
+def tree_dict_in_between_and_below(
+ A: Dict[str, Dict[str, Any]]
+ ,B: Dict[str, Dict[str, Any]]
+) -> tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]:
+ """
+ Partition nodes of B into two topology-based sets relative to A:
+
+ 1. in_between:
+ Nodes in B that lie under at least one directory node in A,
+ but do NOT lie under any leaf directory of A.
+
+ 2. below:
+ Nodes in B that lie under at least one leaf directory of A.
+
+ Definitions (relative to A's directory topology):
+
+ - A directory node in A is any key whose info['node_type'] == 'directory'.
+
+ - A leaf directory in A is a directory that has no *other* directory
+ in A as a proper descendant. The project root ('') is therefore
+ never a leaf (it always has descendant directories if the tree is
+ non-trivial).
+
+ - “Lies under”:
+ * For a path p in B, we look at the chain of directory ancestors
+ (including the root "") and, if p itself is a directory, p
+ itself. Any of those that appear as directory keys in A are
+ considered directory ancestors in A.
+
+ * If any of those ancestors is a leaf in A, p goes to 'below'.
+ Otherwise, if there is at least one directory ancestor in A,
+ p goes to 'in_between'.
+
+ - Nodes in B that do not lie under any directory in A are ignored.
+
+ Returns:
+ (in_between_dict, below_dict), both keyed like B and containing
+ copies of the info dicts from B.
+ """
+ # 1. Collect all directory keys from A
+ A_dir_keys: Set[str] = set(
+ key for key, info in A.items()
+ if info.get("node_type") == "directory"
+ )
+
+ # 2. Compute leaf directories in A
+ leaf_dirs: Set[str] = set()
+
+ for d in A_dir_keys:
+ if d == "":
+ continue
+
+ has_child_dir = False
+ prefix = d + os.sep
+
+ for other in A_dir_keys:
+ if other == d:
+ continue
+ if other.startswith(prefix):
+ has_child_dir = True
+ break
+
+ if not has_child_dir:
+ leaf_dirs.add(d)
+
+ in_between: Dict[str, Dict[str, Any]] = {}
+ below: Dict[str, Dict[str, Any]] = {}
+
+ for key, info in B.items():
+ # Skip B's root
+ if key in ("", "."):
+ continue
+
+ parts = key.split(os.sep)
+
+ # Build directory ancestor chain
+ node_is_dir = (info.get("node_type") == "directory")
+
+ ancestors: List[str] = [""]
+ prefix = None
+
+ if node_is_dir:
+ upto = parts
+ else:
+ upto = parts[:-1]
+
+ for part in upto:
+ if prefix is None:
+ prefix = part
+ else:
+ prefix = os.path.join(prefix, part)
+ ancestors.append(prefix)
+
+ # Filter ancestors to those that exist as directories in A
+ ancestors_in_A = [d for d in ancestors if d in A_dir_keys]
+
+ if not ancestors_in_A:
+ # This B node is not under any directory from A; ignore it.
+ continue
+
+ # Any leaf ancestor in A?
+ has_leaf_ancestor = any(d in leaf_dirs for d in ancestors_in_A)
+
+ if has_leaf_ancestor:
+ below[key] = info
+ else:
+ in_between[key] = info
+
+ if meta.debug_has("tree_dict_in_between_and_below"):
+ merged: Dict[str, Dict[str, Any]] = {}
+ merged.update(in_between)
+ merged.update(below)
+ tree_dict_print(merged)
+
+ return in_between, below
+
+
+def tree_dict_newer(
+ A: Dict[str, Dict[str, Any]]
+ ,B: Dict[str, Dict[str, Any]]
+) -> Dict[str, Dict[str, Any]]:
+ """
+ Return a dictionary of nodes from B that are newer than their
+ corresponding nodes in A.
+
+ For each key k:
+
+ - If k exists in both A and B, and
+ - B[k]['mtime'] > A[k]['mtime'],
+
+ then k is included in the result with value B[k].
+
+ Keys that are only in B (not in A) are ignored here.
+ """
+ result: Dict[str, Dict[str, Any]] = {}
+
+ for key, info_B in B.items():
+ info_A = A.get(key)
+ if info_A is None:
+ continue
+
+ mtime_A = info_A.get("mtime")
+ mtime_B = info_B.get("mtime")
+
+ if mtime_A is None or mtime_B is None:
+ continue
+
+ if mtime_B > mtime_A:
+ result[key] = info_B
+
+ if meta.debug_has("tree_dict_newer"):
+ tree_dict_print(result)
+
+ return result
+
+
+def tree_dict_older(
+ A: Dict[str, Dict[str, Dict[str, Any]]]
+ ,B: Dict[str, Dict[str, Dict[str, Any]]]
+) -> Dict[str, Dict[str, Any]]:
+ """
+ Return a dictionary of nodes from B that are older than their
+ corresponding nodes in A.
+
+ For each key k:
+
+ - If k exists in both A and B, and
+ - B[k]['mtime'] < A[k]['mtime'],
+
+ then k is included in the result with value B[k].
+
+ Keys that are only in B (not in A) are ignored here.
+ """
+ result: Dict[str, Dict[str, Any]] = {}
+
+ for key, info_B in B.items():
+ info_A = A.get(key)
+ if info_A is None:
+ continue
+
+ mtime_A = info_A.get("mtime")
+ mtime_B = info_B.get("mtime")
+
+ if mtime_A is None or mtime_B is None:
+ continue
+
+ if mtime_B < mtime_A:
+ result[key] = info_B
+
+ if meta.debug_has("tree_dict_older"):
+ tree_dict_print(result)
+
+ return result
+
+def in_between_newer(
+ A: TreeDict
+ ,B: TreeDict
+) -> TreeDict:
+ """
+ in_between_newer(A, B) -> TreeDict
+
+ Return the subset of B's nodes that:
+
+ 1. Are in the 'in_between' region with respect to A's topology:
+ - under some directory that exists in A
+ - NOT under any leaf directory in A
+ (as defined by tree_dict_in_between_and_below), and
+
+ 2. For file nodes:
+ - are "newer" than A at the same path, or
+ - are absent from A.
+
+ More precisely:
+ - If A has no entry for that path -> include.
+ - If A has a non-file and B has a file -> include.
+ - If both are files and B.mtime > A.mtime -> include.
+
+ 3. For constrained nodes:
+ - are always included, so that higher-level commands (e.g.
+ 'import') can surface them as "not handled automatically".
+
+ Notes:
+ - Only file nodes participate in mtime comparisons.
+ - Nodes with node_type == 'constrained' are passed through without
+ mtime checks, so that callers can report them separately.
+ """
+ in_between, _below = tree_dict_in_between_and_below(A, B)
+
+ result: TreeDict = {}
+
+ # Keep track of directories already included in the result
+ included_dirs: Set[str] = set()
+
+ # Sort keys to ensure parent directories are processed before their children.
+ # This is crucial for the child exclusion logic to work correctly.
+ sorted_paths = sorted(in_between.keys(), key=len)
+
+ for path in sorted_paths:
+ b_info = in_between[path]
+ b_type = b_info.get("node_type")
+
+ # Constrained nodes: always surface so the caller can list them
+ # under "not handled automatically".
+ # Check if this path is a child of an already included directory
+ is_child_of_included_dir = False
+ for d in included_dirs:
+ if path.startswith(d + os.sep):
+ is_child_of_included_dir = True
+ break
+
+ if is_child_of_included_dir:
+ continue
+
+ # Constrained nodes: always surface so the caller can list them
+ # under "not handled automatically".
+ if b_type == "constrained":
+ result[path] = b_info
+ if b_type == "directory":
+ included_dirs.add(path)
+ continue
+
+
+
+ b_mtime = b_info.get("mtime")
+ a_info = A.get(path)
+
+ # Case 1: path not in A at all -> include (new file/dir in in-between)
+ if a_info is None:
+ result[path] = b_info
+ if b_type == "directory":
+ included_dirs.add(path)
+ continue
+
+ # We only do "newer" semantics for regular files.
+ if b_type != "file":
+ continue
+
+ a_type = a_info.get("node_type")
+
+ # Case 2: A has non-file, B has file -> include
+ if a_type != "file":
+ result[path] = b_info
+ # Note: b_type must be "file" here due to the check above, so no need
+ # to check for directory inclusion.
+ continue
+
+ # Case 3: both are files; compare mtime
+ a_mtime = a_info.get("mtime")
+ if (
+ isinstance(a_mtime, (int, float))
+ and isinstance(b_mtime, (int, float))
+ and b_mtime > a_mtime
+ ):
+ result[path] = b_info
+ # Note: b_type must be "file" here, so no need to check for directory inclusion.
+
+ if meta.debug_has("in_between_newer"):
+ tree_dict_print(result)
+
+ return result
+
--- /dev/null
+#!/usr/bin/env python3
+# ----------------------------------------------------------------------
+# git-empty-dir :: list/mark/clean empty directories, .gitignore aware
+# ----------------------------------------------------------------------
+
+import sys
+import os
+from pathlib import Path
+
+# The source_sync GitIgnore parser is inside the unpacked tool.
+# We assume this directory structure:
+# git-empty-dir/
+# CLI.py
+# source_sync/
+# GitIgnore.py
+#
+# That mirrors how your harmony sync tool is structured.
+
+# Adjust import path so we can load source_sync.*
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE))
+
+from source_sync.GitIgnore import GitIgnore # type: ignore
+
+
+# ----------------------------------------------------------------------
+# helpers
+# ----------------------------------------------------------------------
+
+def load_gitignore_tree(root: Path):
+ """
+ Build a GitIgnore instance rooted at <root>.
+ """
+ return GitIgnore(str(root))
+
+def is_empty_dir(path: Path) -> bool:
+ """
+ A directory is empty if it contains no files or subdirectories.
+ (Hidden files count; .gitignored children are irrelevant because
+ behavior here should reflect real filesystem emptiness.)
+ """
+ try:
+ for _ in path.iterdir():
+ return False
+ return True
+ except PermissionError:
+ # treat as non-empty: safer than aborting
+ return False
+
+
+def has_mark(path: Path, mark_file: str) -> bool:
+ return (path / mark_file).exists()
+
+
+def sorted_dirs(root: Path):
+ """
+ Produce a list of all directories under root, in parent-before-child order.
+ Sort rule:
+ 1. by path length
+ 2. then lexicographically
+ """
+ all_dirs = []
+ for p in root.rglob("*"):
+ if p.is_dir():
+ all_dirs.append(p)
+
+ return sorted(
+ all_dirs
+ ,key = lambda p: (len(p.parts), str(p))
+ )
+
+
+# ----------------------------------------------------------------------
+# traversal
+# ----------------------------------------------------------------------
+
+def visible_dirs(root: Path, ignore_tree, mark_file: str):
+ """
+ Yield all dirs under root, applying:
+ - skip .git
+ - apply .gitignore rules (if a dir is ignored, do not descend)
+ - parent-before-child ordering
+ """
+ for d in sorted_dirs(root):
+ rel = d.relative_to(root)
+
+ if rel == Path("."):
+ continue
+
+ # skip .git explicitly
+ if d.name == ".git":
+ continue
+
+ # .gitignore filtering
+ if ignore_tree.check(str(rel)) == "Ignore":
+ continue
+
+ yield d
+
+
+# ----------------------------------------------------------------------
+# actions
+# ----------------------------------------------------------------------
+
+def action_list(root, ignore_tree, mark_file, mode):
+ """
+ mode ∈ {"empty","marked","all"}
+ """
+ for d in visible_dirs(root, ignore_tree, mark_file):
+ if mode == "all":
+ print(d.relative_to(root))
+ continue
+
+ if mode == "marked":
+ if has_mark(d, mark_file):
+ print(d.relative_to(root))
+ continue
+
+ if mode == "empty":
+ if is_empty_dir(d):
+ print(d.relative_to(root))
+ continue
+
+
+def action_mark(root, ignore_tree, mark_file, mode):
+ """
+ mode ∈ {"empty","all"}
+ """
+ for d in visible_dirs(root, ignore_tree, mark_file):
+ if mode == "empty" and not is_empty_dir(d):
+ continue
+ try:
+ (d / mark_file).touch(exist_ok=True)
+ except Exception:
+ pass
+
+
+def action_clean(root, ignore_tree, mark_file, mode):
+ """
+ mode ∈ {"nonempty","all"}
+ """
+ for d in visible_dirs(root, ignore_tree, mark_file):
+ m = d / mark_file
+ if not m.exists():
+ continue
+
+ if mode == "nonempty":
+ if is_empty_dir(d):
+ continue
+
+ try:
+ m.unlink()
+ except Exception:
+ pass
+
+
+# ----------------------------------------------------------------------
+# usage
+# ----------------------------------------------------------------------
+
+USAGE = """
+usage:
+ git-empty-dir (list|mark|clean) [all|marked|empty] [file-<name>]
+ git-empty-dir help
+ git-empty-dir usage
+
+defaults:
+ mark-file = .gitkeep
+ ignores .git
+ follows .gitignore (no descent into ignored dirs)
+
+examples:
+ git-empty-dir list
+ git-empty-dir list marked file-.githolder
+ git-empty-dir mark
+ git-empty-dir clean all
+"""
+
+
+# ----------------------------------------------------------------------
+# CLI
+# ----------------------------------------------------------------------
+
+def CLI(argv):
+ if len(argv) == 0:
+ print(USAGE)
+ return 0
+
+ cmd = argv[0]
+
+ if cmd in ("help","usage"):
+ print(USAGE)
+ return 0
+
+ # command
+ if cmd not in ("list","mark","clean"):
+ print(f"unknown command: {cmd}")
+ print(USAGE)
+ return 1
+
+ # submode
+ mode = None
+ mark_file = ".gitkeep"
+
+ for a in argv[1:]:
+ if a.startswith("file-"):
+ mark_file = a[5:]
+ continue
+
+ if a in ("all","empty","marked"):
+ mode = a
+ continue
+
+ print(f"unknown argument: {a}")
+ print(USAGE)
+ return 1
+
+ # defaults
+ if cmd == "list":
+ if mode is None:
+ mode = "empty"
+ elif cmd == "mark":
+ if mode is None:
+ mode = "empty"
+ elif cmd == "clean":
+ if mode is None:
+ mode = "nonempty"
+
+ root = Path(".").resolve()
+ ignore_tree = load_gitignore_tree(root)
+
+ if cmd == "list":
+ action_list(root, ignore_tree, mark_file, mode)
+
+ elif cmd == "mark":
+ if mode == "all":
+ action_mark(root, ignore_tree, mark_file, "all")
+ else:
+ action_mark(root, ignore_tree, mark_file, "empty")
+
+ elif cmd == "clean":
+ if mode == "all":
+ action_clean(root, ignore_tree, mark_file, "all")
+ else:
+ action_clean(root, ignore_tree, mark_file, "nonempty")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(CLI(sys.argv[1:]))
--- /dev/null
+../source_sync/Harmony.py
\ No newline at end of file
--- /dev/null
+../source_sync/load_command_module.py
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+meta.py - thin wrappers around command modules
+
+Current responsibilities:
+ 1. Load the incommon 'printenv' command module (no .py extension)
+ using load_command_module.load_command_module().
+ 2. Expose printenv() here, calling the imported printenv() work
+ function with default arguments (equivalent to running without
+ any CLI arguments).
+ 3. Provide a simple version printer for this meta module.
+ 4. Provide a small debug tag API (set/clear/has).
+"""
+
+from __future__ import annotations
+
+import datetime
+from load_command_module import load_command_module
+
+
+# Load the incommon printenv module once at import time
+_PRINTENV_MODULE = load_command_module("printenv")
+_Z_MODULE = load_command_module("Z")
+
+
+# Meta module version
+_major = 1
+_minor = 1
+def version_print() -> None:
+ """
+ Print the meta module version as MAJOR.MINOR.
+ """
+ print(f"{_major}.{_minor}")
+
+
+# Debug tag set and helpers
+_debug = set([
+])
+
+
+def debug_set(tag: str) -> None:
+ """
+ Add a debug tag to the meta debug set.
+ """
+ _debug.add(tag)
+
+
+def debug_clear(tag: str) -> None:
+ """
+ Remove a debug tag from the meta debug set, if present.
+ """
+ _debug.discard(tag)
+
+
+def debug_has(tag: str) -> bool:
+ """
+ Return True if the given debug tag is present.
+ """
+ return tag in _debug
+
+
+# Touch the default tag once so static checkers do not complain about
+# unused helpers when imported purely for side-effects.
+debug_has("Command")
+
+
+def printenv() -> int:
+ """
+ Call the imported printenv() work function with default arguments:
+ - no null termination
+ - no newline quoting
+ - no specific names (print full environment)
+ - prog name 'printenv'
+ """
+ return _PRINTENV_MODULE.printenv(
+ False # null_terminate
+ ,False # quote_newlines
+ ,[] # names
+ ,"printenv"
+ )
+
+
+def z_format_mtime(
+ mtime: float
+) -> str:
+ """
+ Format a POSIX mtime (seconds since epoch, UTC) using the Z module.
+
+ Uses Z.ISO8601_FORMAT and Z.make_timestamp(dt=...).
+ """
+ dt = datetime.datetime.fromtimestamp(mtime, datetime.timezone.utc)
+ return _Z_MODULE.make_timestamp(
+ fmt=_Z_MODULE.ISO8601_FORMAT
+ ,dt=dt
+ )
--- /dev/null
+../source_sync/
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env python3
+"""
+gitignore_walk.py — Fully correct .gitignore-aware depth-first walker
+Now passes:
+ • __pycache__/ (directory listed, contents ignored)
+ • scratchpad/* !/.gitignore
+ • third_party/.gitignore ignoring everything inside
+ • top-level .gitignore
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Generator, List
+
+
+@dataclass(frozen=True)
+class Rule:
+ raw: str
+ negated: bool
+ dir_only: bool # pattern ends with /
+ anchored: bool # pattern starts with /
+ regex: re.Pattern
+
+
+def _compile_rule(line: str) -> Rule | None:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ return None
+
+ negated = line.startswith("!")
+ if negated:
+ line = line[1:].lstrip()
+
+ dir_only = line.endswith("/")
+ if dir_only:
+ line = line[:-1]
+
+ anchored = line.startswith("/")
+ if anchored:
+ line = line[1:]
+
+ # Convert git pattern to regex
+ parts = []
+ i = 0
+ while i < len(line):
+ c = line[i]
+ if c == "*":
+ if i + 1 < len(line) and line[i + 1] == "*":
+ parts.append(".*")
+ i += 2
+ else:
+ parts.append("[^/]*")
+ i += 1
+ elif c == "?":
+ parts.append("[^/]")
+ i += 1
+ else:
+ parts.append(re.escape(c))
+ i += 1
+
+ regex_str = "".join(parts)
+
+ if anchored:
+ regex_str = f"^{regex_str}"
+ else:
+ regex_str = f"(^|/){regex_str}"
+
+ # For dir-only patterns: match path + optional trailing slash
+ if dir_only:
+ regex_str += "(/.*)?$"
+ else:
+ regex_str += "($|/.*$)"
+
+ return Rule(
+ raw=line,
+ negated=negated,
+ dir_only=dir_only,
+ anchored=anchored,
+ regex=re.compile(regex_str),
+ )
+
+
+def _load_rules(dirpath: Path) -> List[Rule]:
+ rules: List[Rule] = []
+ gitignore = dirpath / ".gitignore"
+ if gitignore.is_file():
+ try:
+ for raw_line in gitignore.read_text(encoding="utf-8", errors="ignore").splitlines():
+ rule = _compile_rule(raw_line)
+ if rule:
+ rules.append(rule)
+ except Exception:
+ pass
+ return rules
+
+
+def gitignore_walk(root: str | Path) -> Generator[Path, None, None]:
+ root = Path(root).resolve()
+ if not root.is_dir():
+ return
+
+ # Stack: (directory_path, rules_from_root_to_here)
+ stack: List[tuple[Path, List[Rule]]] = [(root, [])]
+
+ while stack:
+ cur_dir, inherited_rules = stack.pop() # depth-first
+
+ # Load local rules
+ local_rules = _load_rules(cur_dir)
+ all_rules = inherited_rules + local_rules
+
+ # Relative path string from project root
+ try:
+ rel = cur_dir.relative_to(root)
+ rel_str = "" if rel == Path(".") else rel.as_posix()
+ except ValueError:
+ rel_str = ""
+
+ # === Is this directory itself ignored? ===
+ dir_ignored = False
+ for rule in reversed(all_rules): # last match wins
+ if rule.regex.match(rel_str + "/"): # always test as dir
+ dir_ignored = rule.negated
+ break
+
+ # Yield the directory if not ignored
+ if not dir_ignored:
+ yield cur_dir
+
+ # Scan children only if directory is not ignored
+ if dir_ignored:
+ continue
+
+ try:
+ children = list(cur_dir.iterdir())
+ except PermissionError:
+ continue
+
+ children.sort(key=lambda p: p.name.lower())
+
+ to_visit = []
+ for child in children:
+ if child.name == ".git":
+ continue
+
+ child_rel = child.relative_to(root)
+ child_rel_str = child_rel.as_posix()
+
+ # Special case: .gitignore files are never ignored by their own rules
+ if child.name == ".gitignore":
+ if not dir_ignored:
+ yield child
+ continue
+
+ # Evaluate rules against the full relative path
+ ignored = False
+ for rule in reversed(all_rules):
+ match_str = child_rel_str + "/" if child.is_dir() else child_rel_str
+ if rule.regex.match(match_str):
+ ignored = rule.negated
+ break
+
+ if not ignored:
+ if child.is_dir():
+ to_visit.append(child)
+ else:
+ yield child
+
+ # Push children in reverse order → depth-first, left-to-right
+ for child_dir in reversed(to_visit):
+ stack.append((child_dir, all_rules))
+
+
+if __name__ == "__main__":
+ import argparse
+ parser = argparse.ArgumentParser(description="Gitignore-aware tree walk")
+ parser.add_argument("path", nargs="?", default=".", help="Root directory")
+ args = parser.parse_args()
+
+ for p in gitignore_walk(args.path):
+ print(p)
--- /dev/null
+./gitignore_treewalk/CLI.py
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*-
+
+from __future__ import annotations
+
+import os
+import sys
+
+# ----------------------------------------------------------------------
+# Bootstrap import context when executed via symlink (e.g. ../walk)
+# ----------------------------------------------------------------------
+if __name__ == "__main__" and __package__ is None:
+ # Resolve the real file (follows symlinks)
+ _real = os.path.realpath(__file__)
+ _pkg_dir = os.path.dirname(_real)
+ _pkg_root = os.path.dirname(_pkg_dir) # authored/
+
+ # Ensure authored/ is on sys.path
+ if _pkg_root not in sys.path:
+ sys.path.insert(0, _pkg_root)
+
+ # Force package name so relative imports work
+ __package__ = "gitignore_treewalk"
+
+# Now safe to do relative imports
+from .pattern import Pattern
+from .ruleset import RuleSet
+from .walker import Walker
+from .printer import Printer
+
+
+# ----------------------------------------------------------------------
+# Usage text
+# ----------------------------------------------------------------------
+def usage() -> int:
+ print(
+ "Usage:\n"
+ " walk |usage|help\n"
+ " Show this help.\n"
+ "\n"
+ " walk list\n"
+ " Walk the working directory applying gitignore rules.\n"
+ )
+ return 0
+
+
+# ----------------------------------------------------------------------
+# CLI dispatcher
+# ----------------------------------------------------------------------
+def CLI(argv: List[str]) -> int:
+ if not argv:
+ return usage()
+
+ cmd = argv[0]
+
+ if cmd in ("usage", "help"):
+ return usage()
+
+ if cmd == "list":
+ cwd = os.getcwd()
+ cwd_dpa = os.path.abspath(cwd)
+
+ rs = RuleSet.from_gitignore_files(
+ start_dir=cwd_dpa
+ )
+
+ walker = Walker(
+ root=cwd_dpa
+ ,rules=rs
+ )
+
+ for p in walker.walk():
+ print_path(
+ p
+ ,cwd_dpa
+ )
+ return 0
+
+ print(f"Unknown command: {cmd}")
+ return usage()
+
+
+# ----------------------------------------------------------------------
+# Entrypoint
+# ----------------------------------------------------------------------
+if __name__ == "__main__":
+ sys.exit(
+ CLI(
+ sys.argv[1:]
+ )
+ )
--- /dev/null
+# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*-
+"""
+gitignore_treewalk — Git-aware directory traversal library.
+
+Exports:
+ Pattern
+ RuleSet
+ Walker
+ Printer
+"""
+
+from .pattern import Pattern
+from .ruleset import RuleSet
+from .walker import Walker
+from .printer import Printer
--- /dev/null
+# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*-
+
+"""
+pattern.py — Git ignore pattern parser.
+
+Implements:
+ Git pattern semantics:
+ - !negation
+ - directory-only ('foo/')
+ - anchored ('/foo')
+ - wildcards '*', '?'
+ - recursive wildcard '**'
+ - full-path matching
+ - last rule wins
+"""
+
+from __future__ import annotations
+import os
+import re
+from dataclasses import dataclass
+from typing import Optional
+
+
+@dataclass
+class Pattern:
+ raw: str
+ negated: bool
+ anchored: bool
+ dir_only: bool
+ regex: re.Pattern
+
+ @staticmethod
+ def from_line(line: str) -> Optional["Pattern"]:
+ """
+ Parse a single .gitignore pattern line.
+ Return None for comments/empty.
+ """
+
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ return None
+
+ negated = stripped.startswith("!")
+ if negated:
+ stripped = stripped[1:].lstrip()
+ if not stripped:
+ return None
+
+ dir_only = stripped.endswith("/")
+ if dir_only:
+ stripped = stripped[:-1]
+
+ anchored = stripped.startswith("/")
+ if anchored:
+ stripped = stripped[1:]
+
+ # Convert git-style pattern to regex
+ # Git semantics:
+ # ** -> match any depth
+ # * -> match any sequence except '/'
+ # ? -> match one char except '/'
+ #
+ # Always match against full path (unix style, no leading '.')
+ #
+ def escape(s: str) -> str:
+ return re.escape(s)
+
+ # Convert pattern piecewise
+ regex_pieces = []
+ i = 0
+ while i < len(stripped):
+ c = stripped[i]
+ if c == "*":
+ # Check for **
+ if i + 1 < len(stripped) and stripped[i + 1] == "*":
+ # '**' -> match zero or more directories OR characters
+ regex_pieces.append(".*")
+ i += 2
+ else:
+ # '*' -> match any chars except '/'
+ regex_pieces.append("[^/]*")
+ i += 1
+ elif c == "?":
+ regex_pieces.append("[^/]")
+ i += 1
+ else:
+ regex_pieces.append(escape(c))
+ i += 1
+
+ regex_string = "".join(regex_pieces)
+
+ # Anchored: match from start of path
+ # Unanchored: match anywhere in path
+ if anchored:
+ full = fr"^{regex_string}$"
+ else:
+ full = fr"(^|/){regex_string}($|/)"
+
+ return Pattern(
+ raw=line,
+ negated=negated,
+ anchored=anchored,
+ dir_only=dir_only,
+ regex=re.compile(full),
+ )
+
+ def matches(self, relpath: str, is_dir: bool) -> bool:
+ """
+ Match full relative path, not just basename.
+ """
+ # If pattern is directory-only, relpath must be a directory
+ if self.dir_only and not is_dir:
+ return False
+
+ return bool(self.regex.search(relpath))
--- /dev/null
+# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*-
+
+"""
+printer.py — utilities for printing path listings:
+ - linear list
+ - ASCII "tree" view where each line begins with the actual path,
+ then optional visual decoration for humans.
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Iterable
+
+
+class Printer:
+ @staticmethod
+ def print_linear(paths: Iterable[Path], cwd: Path) -> None:
+ for p in paths:
+ rel = p.relative_to(cwd)
+ print(rel.as_posix())
+
+ @staticmethod
+ def print_tree(paths: Iterable[Path], cwd: Path) -> None:
+ """
+ Print each line as:
+ <relpath> <drawing>
+
+ Where <drawing> is ASCII tree structure.
+ """
+ items = sorted(paths, key=lambda p: p.relative_to(cwd).as_posix())
+ rels = [p.relative_to(cwd).as_posix() for p in items]
+
+ # Build a tree prefix for human reading
+ for rel in rels:
+ parts = rel.split("/")
+ indent = " " * (len(parts) - 1)
+ branch = "└─ " if len(parts) > 1 else ""
+ print(f"{rel} {indent}{branch}")
--- /dev/null
+# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*-
+
+"""
+ruleset.py — layered Git ignore rule-set stack.
+
+Implements the Git semantics:
+ - Each directory can contribute patterns from .gitignore
+ - Parent directories apply first
+ - Last matching pattern wins
+ - Negation overrides earlier ignores
+ - dir-only rules respected
+"""
+
+from __future__ import annotations
+import os
+from typing import List, Optional
+from .pattern import Pattern
+
+
+class RuleSet:
+ """
+ Manages a stack of patterns from:
+ - global excludes
+ - .git/info/exclude
+ - directory-local .gitignore
+
+ push(patterns)
+ pop(count)
+ evaluate(path, is_dir)
+ """
+
+ def __init__(self) -> None:
+ self.stack: List[List[Pattern]] = []
+
+ def push(self, patterns: List[Pattern]) -> None:
+ self.stack.append(patterns)
+
+ def pop(self) -> None:
+ if self.stack:
+ self.stack.pop()
+
+ def evaluate(self, relpath: str, is_dir: bool) -> bool:
+ """
+ Return True iff path is ignored.
+ Last matching rule wins.
+ """
+ verdict: Optional[bool] = None
+
+ for group in self.stack:
+ for pat in group:
+ if pat.matches(relpath, is_dir):
+ if pat.negated:
+ verdict = False
+ else:
+ verdict = True
+
+ return bool(verdict)
--- /dev/null
+# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*-
+
+"""
+walker.py — Git-aware directory traversal.
+
+Features:
+ - Loads global excludes
+ - Loads .git/info/exclude if present
+ - Loads .gitignore in each directory
+ - Does NOT descend into ignored directories
+ - Yields both files and directories (Path objects)
+ - Always parent-before-child
+ - Sorted lexicographically
+"""
+
+from __future__ import annotations
+import os
+from pathlib import Path
+from typing import Iterator, List
+
+from .pattern import Pattern
+from .ruleset import RuleSet
+
+
+class Walker:
+ def __init__(self, root: Path) -> None:
+ self.root = root.resolve()
+ self.ruleset = RuleSet()
+
+ # Load global and project-local excludes
+ self._push_global_excludes()
+ self._push_local_excludes()
+
+ # ----------------------------------------------------------------------
+ # Exclude Sources
+ # ----------------------------------------------------------------------
+
+ def _push_global_excludes(self) -> None:
+ """
+ Load user's global ignore file if present:
+ ~/.config/git/ignore
+ or ~/.gitignore_global
+ """
+ candidates = [
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "git" / "ignore",
+ Path.home() / ".gitignore_global"
+ ]
+ patterns = []
+
+ for f in candidates:
+ if f.exists():
+ for line in f.read_text().splitlines():
+ p = Pattern.from_line(line)
+ if p:
+ patterns.append(p)
+ break
+
+ if patterns:
+ self.ruleset.push(patterns)
+
+ def _push_local_excludes(self) -> None:
+ """
+ Load <root>/.git/info/exclude
+ """
+ f = self.root / ".git" / "info" / "exclude"
+ patterns = []
+ if f.exists():
+ for line in f.read_text().splitlines():
+ p = Pattern.from_line(line)
+ if p:
+ patterns.append(p)
+
+ if patterns:
+ self.ruleset.push(patterns)
+
+ # ----------------------------------------------------------------------
+ # Walk
+ # ----------------------------------------------------------------------
+
+ def walk(self) -> Iterator[Path]:
+ return self._walk_dir(self.root, prefix="")
+
+ def _walk_dir(self, dpath: Path, prefix: str) -> Iterator[Path]:
+ # Load .gitignore for this directory
+ patterns = []
+ gitignore = dpath / ".gitignore"
+ if gitignore.exists():
+ for line in gitignore.read_text().splitlines():
+ p = Pattern.from_line(line)
+ if p:
+ patterns.append(p)
+
+ self.ruleset.push(patterns)
+
+ # Evaluate this directory (except root)
+ if prefix:
+ if self.ruleset.evaluate(prefix, is_dir=True):
+ # ignored directories are NOT descended into
+ self.ruleset.pop()
+ return
+
+ yield dpath
+
+ # Enumerate children sorted
+ entries: List[Path] = sorted(dpath.iterdir(), key=lambda p: p.name)
+
+ for entry in entries:
+ rel = entry.relative_to(self.root).as_posix()
+ is_dir = entry.is_dir()
+
+ # Skip ignored
+ if self.ruleset.evaluate(rel, is_dir=is_dir):
+ continue
+
+ # Directories
+ if is_dir:
+ yield from self._walk_dir(entry, rel)
+ else:
+ yield entry
+
+ self.ruleset.pop()
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+from __future__ import annotations
+
+import os
+import sys
+from typing import Sequence
+
+import walk
+
+
+COMMAND_set: set[str] = set([
+ "usage"
+ ,"help"
+ ,"all"
+])
+
+
+def print_usage() -> None:
+ prog_name = os.path.basename(sys.argv[0] or "CLI.py")
+ print(f"Usage: {prog_name} <command>")
+ print("Commands:")
+ print(" usage - print this usage message")
+ print(" help - print this usage message")
+ print(" all - walk current directory and print directories with indentation")
+
+
+def CLI(
+ argv: Sequence[str] | None = None
+) -> int:
+ """
+ Minimal CLI to exercise the TM (Tape Machine over the FS tree).
+
+ Commands:
+ usage, help:
+ Print usage information.
+
+ all:
+ Call walk.print_indent('.') to walk the current directory
+ and print directories with indentation.
+ """
+ if argv is None:
+ argv = sys.argv[1:]
+
+ if len(argv) != 1:
+ print_usage()
+ return 1
+
+ cmd = argv[0]
+
+ if cmd not in COMMAND_set:
+ print(f"Unrecognized command: {cmd}")
+ print_usage()
+ return 2
+
+ if cmd in ("usage", "help"):
+ print_usage()
+ return 0
+
+ if cmd == "all":
+ root_dp = "."
+ status = walk.print_indent(root_dp)
+ return status
+
+ # Should be unreachable if COMMAND_set and logic stay aligned.
+ print("Internal error: command dispatch fell through.")
+ return 9
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+from __future__ import annotations
+
+from typing import Any, Generic, Iterable, List, Optional, TypeVar
+
+import meta
+
+
+T = TypeVar("T")
+
+
+def _format_item_for_debug(
+ item: Any
+) -> str:
+ """
+ Convert a queued item to a human-readable string for debug output.
+
+ If the item has a callable .print() method, we call it and use
+ the returned value (converted to str). Otherwise we fall back
+ to repr(item).
+ """
+ if hasattr(item, "print"):
+ candidate = getattr(item, "print")
+ if callable(candidate):
+ try:
+ text = candidate()
+ if text is None:
+ return repr(item)
+ return str(text)
+ except Exception:
+ return repr(item)
+ return repr(item)
+
+
+def _queue_debug(
+ queue: Any
+ ,op: str
+ ,item: Any | None = None
+) -> None:
+ """
+ Internal helper: emit a queue-related debug message if
+ the meta.DEBUG set contains the tag "Queue".
+
+ Includes the queue instance pointer in hex so we can
+ distinguish multiple Queue instances.
+ """
+ if "Queue" not in meta.DEBUG:
+ return
+
+ qid = hex(id(queue))
+
+ if item is None:
+ print(f"[Queue {qid}] {op}")
+ else:
+ desc = _format_item_for_debug(item)
+ print(f"[Queue {qid}] {op}: {desc}")
+
+
+class Queue(Generic[T]):
+ """
+ Queue — ordered queue of items (e.g., Context objects).
+
+ Semantics:
+
+ - The "front" of the queue is the current item.
+ - The "back" of the queue will eventually be visited.
+ - Popping an empty queue is an error.
+
+ Interface:
+
+ front() -> T : current item (head), does not remove it.
+ back() -> T : last item in the queue, does not remove it.
+ top() -> T : alias for front().
+ depth() -> int : number of entries in the queue.
+ pop() -> T : remove and return the front item.
+
+ push(where_tag, item: T) -> None:
+
+ where_tag:
+ "front" -> insert item at front of queue
+ "back" -> append item at back of queue
+ "throw-away" -> do not store, treated as intentionally ignored
+
+ clone() -> Queue[T]
+
+ Creates a copy of the Queue. For each item:
+ - If item has a .clone() method, it is called.
+ - Otherwise the item reference is reused.
+ """
+
+ def __init__(
+ self
+ ,initial_iter: Optional[Iterable[T]] = None
+ ) -> None:
+ if initial_iter is None:
+ self._queue_list: List[T] = []
+ else:
+ self._queue_list = list(initial_iter)
+
+ # ----------------------------------------------------------------------
+ # Basic properties
+ # ----------------------------------------------------------------------
+ def is_empty(self) -> bool:
+ return len(self._queue_list) == 0
+
+ def depth(self) -> int:
+ return len(self._queue_list)
+
+ # ----------------------------------------------------------------------
+ # Cloning
+ # ----------------------------------------------------------------------
+ def clone(self) -> "Queue[T]":
+ """
+ Create a copy of this Queue.
+
+ The new queue has its own internal list. If an item provides
+ a .clone() method, that is used to copy the item; otherwise
+ the item reference is reused.
+ """
+ new_item_list: List[T] = []
+ for item in self._queue_list:
+ if hasattr(item, "clone") and callable(getattr(item, "clone")):
+ new_item_list.append(item.clone()) # type: ignore[arg-type]
+ else:
+ new_item_list.append(item)
+ new_q = Queue[T](new_item_list)
+ _queue_debug(self, f"clone -> new {hex(id(new_q))}")
+ return new_q
+
+ # ----------------------------------------------------------------------
+ # Accessors
+ # ----------------------------------------------------------------------
+ def front(self) -> T:
+ if self.is_empty():
+ raise RuntimeError("Queue.front() on empty queue.")
+ return self._queue_list[0]
+
+ def back(self) -> T:
+ if self.is_empty():
+ raise RuntimeError("Queue.back() on empty queue.")
+ return self._queue_list[-1]
+
+ def top(self) -> T:
+ """
+ Alias for front().
+ """
+ return self.front()
+
+ # ----------------------------------------------------------------------
+ # Mutating operations
+ # ----------------------------------------------------------------------
+ def pop(self) -> T:
+ """
+ Remove and return the front item.
+
+ Popping an empty queue is an error.
+ """
+ if self.is_empty():
+ raise RuntimeError("Queue.pop() on empty queue.")
+ item = self._queue_list.pop(0)
+ _queue_debug(self, "pop", item)
+ return item
+
+ def push(
+ self
+ ,where_tag: str
+ ,item: T
+ ) -> None:
+ """
+ Push an item according to where_tag:
+
+ where_tag == "front":
+ insert item at front of the queue.
+
+ where_tag == "back":
+ append item at back of the queue.
+
+ where_tag == "throw-away":
+ do not store item; treated as intentionally ignored.
+
+ Any other where_tag is an error.
+ """
+ if where_tag == "front":
+ self._queue_list.insert(0, item)
+ _queue_debug(self, "push front", item)
+ return
+
+ if where_tag == "back":
+ self._queue_list.append(item)
+ _queue_debug(self, "push back", item)
+ return
+
+ if where_tag == "throw-away":
+ _queue_debug(self, "push throw-away (discard)", item)
+ return
+
+ raise ValueError(f"Unknown Queue push tag: {where_tag!r}")
+
+ # ----------------------------------------------------------------------
+ # Dispatcher
+ # ----------------------------------------------------------------------
+ def dispatch(
+ self
+ ,op: str
+ ,*args
+ ,**kwargs
+ ):
+ """
+ Generic dispatcher for method-tag control.
+
+ Supported op values:
+
+ "front" -> front()
+ "back" -> back()
+ "top" -> top()
+ "depth" -> depth()
+ "pop" -> pop()
+
+ "push" -> push(where_tag, item)
+
+ Any unknown op raises ValueError.
+ """
+ if op == "front":
+ return self.front()
+ if op == "back":
+ return self.back()
+ if op == "top":
+ return self.top()
+ if op == "depth":
+ return self.depth()
+ if op == "pop":
+ return self.pop()
+ if op == "push":
+ if len(args) != 2:
+ raise ValueError("dispatch('push', where_tag, item) requires 2 arguments.")
+ where_tag = args[0]
+ item = args[1]
+ self.push(where_tag, item)
+ return None
+
+ raise ValueError(f"Unknown Queue operation: {op!r}")
+
+
+# ----------------------------------------------------------------------
+# Simple test harness
+# ----------------------------------------------------------------------
+class TestItem:
+ """
+ Minimal test item for the __main__ test().
+
+ It implements .print() so Queue can use it in debug messages.
+ """
+
+ def __init__(
+ self
+ ,name: str
+ ) -> None:
+ self.name = name
+
+ def print(self) -> str:
+ return f"TestItem({self.name})"
+
+ def clone(self) -> "TestItem":
+ # For the test, clone is just another instance with same name.
+ return TestItem(self.name)
+
+
+def test() -> None:
+ """
+ Simple test for Queue with debug enabled.
+ """
+ meta.DEBUG.add("Queue")
+
+ print("Queue test starting...")
+ meta.version()
+
+ q = Queue[TestItem]()
+ q2 = q.clone() # exercise clone-debug once at the start
+
+ q.push("front", TestItem("root"))
+ q.push("back", TestItem("child-A"))
+ q.push("back", TestItem("child-B"))
+ q.push("throw-away", TestItem("ignored-node"))
+
+ print(f"depth = {q.depth()}")
+ print(f"front = {q.front().print()}")
+ print(f"back = {q.back().print()}")
+
+ popped = q.pop()
+ print(f"popped = {popped.print()}")
+ print(f"depth = {q.depth()}")
+
+ print("Queue test done.")
+
+
+if __name__ == "__main__":
+ test()
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+import meta
+
+
+def _tape_debug(tm: Any, msg: str) -> None:
+ """Emit a TapeMachine-related debug message if meta.DEBUG has 'TapeMachine'."""
+ if "TapeMachine" not in meta.DEBUG: return
+ print(f"[TapeMachine {hex(id(tm))}] {msg}")
+
+
+class TapeMachine:
+ """
+ TapeMachine — single-tape, single-head, first-rest pattern.
+
+ - Tape is non-empty.
+ - Head starts on cell 0 and is always on a valid cell.
+ - Movement contract: caller checks can_step(n) before step(n).
+ """
+
+ def __init__(self, tape: Optional[Any] = None) -> None:
+ if tape is None: tape = []
+ for name in ("__len__", "__getitem__", "__setitem__"):
+ if not hasattr(tape, name): raise TypeError(f"TapeMachine tape must support {name}: {tape!r}")
+ self._length: int = len(tape)
+ if self._length == 0: raise ValueError("TapeMachine requires a non-empty tape (first-rest pattern).")
+ self._tape = tape
+ self._head_position: int = 0
+ _tape_debug(self, f"init length={self._length}, head_position=0")
+
+ @property
+ def length(self) -> int:
+ """Length of the tape (number of cells)."""
+ return self._length
+
+ def cue(self, new_position: Optional[int] = None) -> int:
+ """Get or set head (cue) position."""
+ if new_position is None: return self._head_position
+ if not 0 <= new_position < self._length: raise IndexError(f"TapeMachine.cue: out of range ({new_position}).")
+ self._head_position = new_position
+ return self._head_position
+
+ def can_step(self, n: int = 1) -> bool:
+ """True if step(n) would keep the head on the tape."""
+ proposed_position = self._head_position + n
+ return 0 <= proposed_position < self.length
+
+ def rewind(self) -> None:
+ """Move head to the first cell (index 0)."""
+ self._head_position = 0
+
+ def cue_end(self) -> None:
+ """Move head to the last cell (index length-1)."""
+ self._head_position = self._length - 1
+
+ def step(self, n: int = 1) -> None:
+ """Move head by n cells (n>0 right, n<0 left, n=0 no-op); caller must ensure can_step(n)."""
+ self._head_position += n
+
+ def read(self) -> Any:
+ """Read value under head."""
+ return self._tape[self._head_position]
+
+ def write(self, value: Any) -> None:
+ """Write a new value into the current cell."""
+ self._tape[self._head_position] = value
+
+ def print(self) -> str:
+ """
+ Printable view of the tape.
+
+ Two spaces then value per line; line with the head is prefixed '* '.
+ """
+ line_list = []
+ for idx in range(self._length):
+ prefix = "* " if idx == self._head_position else " "
+ line_list.append(f"{prefix}{self._tape[idx]}")
+ return "\n".join(line_list)
+
+ def entangle(self) -> "TapeMachine":
+ """Create an entangled copy: shared tape, independent head_position."""
+ new_tm = object.__new__(TapeMachine)
+ new_tm._tape = self._tape
+ new_tm._length = self._length
+ new_tm._head_position = self._head_position
+ _tape_debug(self, f"entangle -> new {hex(id(new_tm))} head_position={self._head_position}")
+ return new_tm
+
+
+def test() -> None:
+ """Simple TapeMachine test with first-rest semantics."""
+ meta.DEBUG.add("TapeMachine")
+ print("TapeMachine test starting..."); meta.version()
+
+ tm = TapeMachine(["apple", "orange", "tangerine"])
+ print(f"initial: cue={tm.cue()}, value={tm.read()!r}")
+
+ while tm.can_step(1):
+ tm.step(1)
+ print(f"step(+1): cue={tm.cue()}, value={tm.read()!r}")
+
+ if tm.can_step(-1):
+ tm.step(-1)
+ print(f"step(-1): cue={tm.cue()}, value={tm.read()!r}")
+
+ tm.rewind()
+ print(f"rewind: cue={tm.cue()}, value={tm.read()!r}")
+
+ tm2 = tm.entangle()
+ if tm.can_step(2):
+ tm.step(2)
+ print(f"tm step(+2): cue={tm.cue()}, value={tm.read()!r}")
+ if tm2.can_step(1):
+ tm2.step(1)
+ print(f"tm2 step(+1): cue={tm2.cue()}, value={tm2.read()!r}")
+
+ tm.cue_end()
+ print(f"cue_end: cue={tm.cue()}, value={tm.read()!r}")
+
+ print("print(tm):")
+ print(tm.print())
+ print("TapeMachine test done.")
+
+
+if __name__ == "__main__":
+ test()
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+from __future__ import annotations
+
+import os
+from typing import Any, List, Optional, Tuple
+
+import meta
+from Queue import Queue
+from TapeMachine import TapeMachine
+
+
+def _tm_debug(tm: Any, msg: str) -> None:
+ """Emit a TreeMachine-related debug message if meta.DEBUG has 'TreeMachine'."""
+ if "TreeMachine" not in meta.DEBUG: return
+ print(f"[TreeMachine {hex(id(tm))}] {msg}")
+
+
+class Context:
+ """
+ Context - per-directory iteration state for TreeMachine.
+
+ Fields:
+
+ dir_dpr : directory path, relative to root ('.' for root).
+ level : integer >= 1 representing depth; root has level == 1.
+
+ _child_tm : TapeMachine over the child entry names.
+ _started : False until the first step() for this context.
+
+ Contract:
+
+ - Context is only constructed for directories with at least one child.
+ (Empty directories never become Contexts.)
+
+ - First step:
+
+ _started is False, _child_tm.cue() == 0.
+ step() sets _started = True and returns index 0.
+
+ - Subsequent steps:
+
+ step() uses TapeMachine.can_step(1)/step(1) to move to the
+ next child and returns its index.
+
+ - rightmost():
+
+ True when this context cannot be stepped further without error.
+ """
+
+ def __init__(self, dir_dpr: str, level: int, child_name_list: List[str]) -> None:
+ if not child_name_list:
+ raise ValueError("Context requires non-empty child_name_list.")
+ self.dir_dpr: str = os.path.normpath(dir_dpr)
+ self.level: int = level
+ self._child_tm: TapeMachine = TapeMachine(child_name_list)
+ self._started: bool = False
+
+ def clone(self) -> "Context":
+ """Clone this Context so that the copy can be advanced independently."""
+ new_ctx = object.__new__(Context)
+ new_ctx.dir_dpr = self.dir_dpr
+ new_ctx.level = self.level
+ new_ctx._child_tm = self._child_tm.entangle()
+ new_ctx._started = self._started
+ return new_ctx
+
+ def rightmost(self) -> bool:
+ """
+ True when this context cannot be stepped further without error.
+
+ For a directory with N children:
+
+ - If not started yet:
+ N == 1 -> True (single child; only one step possible)
+ N > 1 -> False (more than one child)
+
+ - If started:
+ True iff we cannot move one more child to the right.
+ """
+ if not self._started:
+ return self._child_tm.length <= 1
+ return not self._child_tm.can_step(1)
+
+ def step(self) -> int:
+ """
+ Advance within this directory.
+
+ Returns:
+ index (0-based) into this directory's child list.
+
+ Raises:
+ StopIteration if there is no valid next position.
+ """
+ if not self._started:
+ # First step: stay at cue 0, just mark started.
+ self._started = True
+ return self._child_tm.cue()
+
+ if not self._child_tm.can_step(1):
+ raise StopIteration("Context.step() would move past rightmost child.")
+
+ self._child_tm.step(1)
+ return self._child_tm.cue()
+
+ def current_child_name(self) -> str:
+ """
+ Return the current child's basename in this directory.
+
+ Raises:
+ RuntimeError if step() has never been successfully called.
+ """
+ if not self._started:
+ raise RuntimeError("Context.current_child_name() with no current child.")
+ return self._child_tm.read()
+
+ def current_child_dpr(self) -> str:
+ """
+ Return the current child's path, relative to the TreeMachine root.
+
+ If dir_dpr == '.', this is just the child name.
+ Otherwise it is 'dir_dpr/child_name'.
+ """
+ child_name = self.current_child_name()
+ if self.dir_dpr == ".": return child_name
+ return os.path.join(self.dir_dpr, child_name)
+
+ def current_child_address(self) -> Tuple[int, int]:
+ """
+ Return the address (level, index) of the current child.
+
+ level = directory level + 1
+ index = index within this directory's child list
+ """
+ if not self._started:
+ raise RuntimeError("Context.current_child_address() with no current child.")
+ return (self.level + 1, self._child_tm.cue())
+
+ def print(self) -> str:
+ """Printable representation for debugging."""
+ n = self._child_tm.length
+ idx = self._child_tm.cue() if self._started else -1
+ return f"Context(dir_dpr={self.dir_dpr!r}, level={self.level}, index={idx}, n={n})"
+
+
+class TreeMachine:
+ """
+ TreeMachine - multihead iterator over a directory tree.
+
+ Initialization:
+
+ TreeMachine(root_dp)
+
+ root_dp:
+ directory path (relative or absolute) that defines the
+ top of the tree to be walked.
+
+ Behavior:
+
+ - Visits file system nodes (files, directories, other) under root.
+ - Directories that have children get a Context and join the queue.
+ - Directories with no children are still visited as nodes, but are
+ never used as Contexts (no descent possible).
+ """
+
+ def __init__(self, root_dp: str) -> None:
+ root_dpa = os.path.abspath(root_dp)
+ if not os.path.isdir(root_dpa):
+ raise ValueError(f"TreeMachine root must be a directory: {root_dp!r}")
+
+ self.root_dpa: str = root_dpa
+
+ try:
+ child_name_list = sorted(os.listdir(self.root_dpa))
+ except OSError:
+ child_name_list = []
+
+ if child_name_list:
+ root_ctx = Context(".", 1, child_name_list)
+ self._context_queue: Queue[Context] = Queue([root_ctx])
+ else:
+ # Root has no children; no Contexts to descend into.
+ self._context_queue = Queue([])
+
+ self._current_fs_nod_pr: Optional[str] = None
+ self._address: Optional[Tuple[int, int]] = None # (level, index)
+
+ _tm_debug(self, f"init root_dpa={self.root_dpa!r}")
+
+ # --------------------------------------------------------------------
+ # Internal helpers
+ # --------------------------------------------------------------------
+ def _current_context(self) -> Context:
+ if self._context_queue.is_empty():
+ raise RuntimeError("TreeMachine has no active context (empty queue).")
+ return self._context_queue.front()
+
+ def _fs_nod_pr_to_dpa(self, fs_nod_pr: str) -> str:
+ """Convert a relative node path (from root) to an absolute path."""
+ if fs_nod_pr == ".": return self.root_dpa
+ return os.path.join(self.root_dpa, fs_nod_pr)
+
+ def _compute_property_list(self, fs_nod_pr: str) -> List[str]:
+ """Compute property_list for a node, given its path relative to root."""
+ prop_list: List[str] = []
+ fs_nod_pa = self._fs_nod_pr_to_dpa(fs_nod_pr)
+
+ if os.path.isdir(fs_nod_pa):
+ prop_list.append("directory")
+ try:
+ child_name_list = os.listdir(fs_nod_pa)
+ except OSError:
+ child_name_list = []
+ if len(child_name_list) == 0:
+ prop_list.append("empty")
+ elif os.path.isfile(fs_nod_pa):
+ prop_list.append("file")
+ else:
+ prop_list.append("other")
+
+ return prop_list
+
+ # --------------------------------------------------------------------
+ # Core movement & observation
+ # --------------------------------------------------------------------
+ def step(self) -> Tuple[int, int]:
+ """
+ Advance the TreeMachine head within the current directory context.
+
+ Returns:
+ address = (level, index) for the new node under the head.
+
+ Raises:
+ StopIteration if there is no active context or the current
+ context is already at its rightmost cell.
+ """
+ ctx = self._current_context()
+
+ try:
+ _ = ctx.step()
+ except StopIteration:
+ raise StopIteration("TreeMachine.step(): current context has no next node.")
+
+ fs_nod_pr = ctx.current_child_dpr()
+ address = ctx.current_child_address()
+
+ self._current_fs_nod_pr = fs_nod_pr
+ self._address = address
+
+ _tm_debug(self, f"step -> fs_nod_pr={fs_nod_pr!r}, address={address}")
+ return address
+
+ def read(self):
+ """
+ Read the value under the head.
+
+ Returns:
+ (value, property_list, address)
+
+ value:
+ path string, relative to the TreeMachine root.
+
+ property_list:
+ list drawn from {"directory","file","other","empty"}.
+
+ address:
+ (level, index) for the current node.
+
+ Raises:
+ RuntimeError if there is no current node (no successful step yet).
+ """
+ if self._current_fs_nod_pr is None or self._address is None:
+ raise RuntimeError("TreeMachine.read() with no current node; call step() first.")
+
+ value = self._current_fs_nod_pr
+ prop_list = self._compute_property_list(value)
+ address = self._address
+
+ return (value, prop_list, address)
+
+ def rightmost(self) -> bool:
+ """True when the current context cannot be stepped further."""
+ ctx = self._current_context()
+ return ctx.rightmost()
+
+ # --------------------------------------------------------------------
+ # Context queue interaction
+ # --------------------------------------------------------------------
+ def push_current_directory(self, where_tag: str) -> None:
+ """
+ If the current node is a directory with children, create a new
+ Context for it and push it into the context queue according
+ to where_tag:
+
+ "front" -> visit this directory next (depth-first flavor)
+ "back" -> visit this directory later (breadth-first flavor)
+ "throw-away" -> ignore this directory entirely
+
+ If the current node is not a directory, or is an empty directory,
+ this is a no-op (with debug).
+ """
+ if self._current_fs_nod_pr is None or self._address is None:
+ raise RuntimeError("push_current_directory() with no current node; call step() first.")
+
+ fs_nod_pr = self._current_fs_nod_pr
+ prop_list = self._compute_property_list(fs_nod_pr)
+
+ if "directory" not in prop_list:
+ _tm_debug(self, f"push_current_directory skipped (not a directory): {fs_nod_pr!r}")
+ return
+
+ fs_nod_pa = self._fs_nod_pr_to_dpa(fs_nod_pr)
+ try:
+ child_name_list = sorted(os.listdir(fs_nod_pa))
+ except OSError:
+ child_name_list = []
+
+ if not child_name_list:
+ _tm_debug(self, f"push_current_directory skipped (empty directory): {fs_nod_pr!r}")
+ return
+
+ level, _idx = self._address
+ new_ctx = Context(fs_nod_pr, level, child_name_list)
+ self._context_queue.push(where_tag, new_ctx)
+ _tm_debug(self, f"push_current_directory -> {where_tag} {new_ctx.print()}")
+
+ def pop_context(self) -> None:
+ """
+ Pop the current directory context from the queue.
+
+ After popping, the new current context (if any) has no current
+ node yet, so the TreeMachine head is cleared.
+ """
+ popped = self._context_queue.pop()
+ _tm_debug(self, f"pop_context -> {popped.print()}")
+
+ self._current_fs_nod_pr = None
+ self._address = None
+
+ def context_depth(self) -> int:
+ """Return the number of directory contexts currently in the queue."""
+ return self._context_queue.depth()
+
+ # --------------------------------------------------------------------
+ # Entanglement
+ # --------------------------------------------------------------------
+ def entangle(self) -> "TreeMachine":
+ """
+ Create an entangled copy of this TreeMachine.
+
+ The entangled copy:
+
+ - Has the same root_dpa.
+ - Has a cloned Queue of Contexts (using Context.clone()).
+ - Starts with the same current node (fs_nod_pr) and address.
+
+ Afterwards, the original and entangled copy evolve independently.
+ """
+ new_tm = object.__new__(TreeMachine)
+ new_tm.root_dpa = self.root_dpa
+ new_tm._context_queue = self._context_queue.clone()
+ new_tm._current_fs_nod_pr = self._current_fs_nod_pr
+ new_tm._address = None if self._address is None else (self._address[0], self._address[1])
+ _tm_debug(self, f"entangle -> new {hex(id(new_tm))}")
+ return new_tm
+
+
+def test() -> None:
+ """
+ Simple TreeMachine test.
+
+ It walks a handful of nodes from the current directory, pushing
+ directory nodes to the back of the context queue (breadth-first flavor).
+ """
+ meta.DEBUG.add("Queue")
+ meta.DEBUG.add("TreeMachine")
+
+ print("TreeMachine test starting...")
+ meta.version()
+
+ tm = TreeMachine(".")
+
+ steps = 0
+ max_steps = 20
+
+ while steps < max_steps:
+ if tm._context_queue.is_empty():
+ print("no more contexts in queue; stopping")
+ break
+
+ try:
+ address = tm.step()
+ except StopIteration:
+ print("TreeMachine: StopIteration from step()")
+ break
+
+ value, prop_list, addr = tm.read()
+ print(f"step {steps}: value={value!r}, props={prop_list}, address={addr}")
+
+ if "directory" in prop_list and "empty" not in prop_list:
+ tm.push_current_directory("back")
+
+ if tm.rightmost():
+ print(" current context is rightmost; popping context")
+ tm.pop_context()
+
+ steps += 1
+
+ print("TreeMachine test done.")
+
+
+if __name__ == "__main__":
+ test()
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+from __future__ import annotations
+
+"""
+meta.py - module-level metadata and debug flags for the TreeMachine tools.
+"""
+
+# Version components are local to this module.
+_MAJOR = 0
+_MINOR = 2
+
+# Debug flags:
+#
+# "Queue" -> enable debug for Queue operations.
+# "TreeMachine" -> enable debug for TreeMachine operations.
+#
+DEBUG: set[str] = set()
+
+
+def version() -> None:
+ """
+ Print the current version as:
+
+ version <major> <minor>
+ """
+ print(f"version {_MAJOR} {_MINOR}")
+++ /dev/null
-#!/usr/bin/env python3
-# ----------------------------------------------------------------------
-# git-empty-dir :: list/mark/clean empty directories, .gitignore aware
-# ----------------------------------------------------------------------
-
-import sys
-import os
-from pathlib import Path
-
-# The source_sync GitIgnore parser is inside the unpacked tool.
-# We assume this directory structure:
-# git-empty-dir/
-# CLI.py
-# source_sync/
-# GitIgnore.py
-#
-# That mirrors how your harmony sync tool is structured.
-
-# Adjust import path so we can load source_sync.*
-HERE = Path(__file__).resolve().parent
-sys.path.insert(0, str(HERE))
-
-from source_sync.GitIgnore import GitIgnore # type: ignore
-
-
-# ----------------------------------------------------------------------
-# helpers
-# ----------------------------------------------------------------------
-
-def load_gitignore_tree(root: Path):
- """
- Build a GitIgnore instance rooted at <root>.
- """
- return GitIgnore(str(root))
-
-def is_empty_dir(path: Path) -> bool:
- """
- A directory is empty if it contains no files or subdirectories.
- (Hidden files count; .gitignored children are irrelevant because
- behavior here should reflect real filesystem emptiness.)
- """
- try:
- for _ in path.iterdir():
- return False
- return True
- except PermissionError:
- # treat as non-empty: safer than aborting
- return False
-
-
-def has_mark(path: Path, mark_file: str) -> bool:
- return (path / mark_file).exists()
-
-
-def sorted_dirs(root: Path):
- """
- Produce a list of all directories under root, in parent-before-child order.
- Sort rule:
- 1. by path length
- 2. then lexicographically
- """
- all_dirs = []
- for p in root.rglob("*"):
- if p.is_dir():
- all_dirs.append(p)
-
- return sorted(
- all_dirs
- ,key = lambda p: (len(p.parts), str(p))
- )
-
-
-# ----------------------------------------------------------------------
-# traversal
-# ----------------------------------------------------------------------
-
-def visible_dirs(root: Path, ignore_tree, mark_file: str):
- """
- Yield all dirs under root, applying:
- - skip .git
- - apply .gitignore rules (if a dir is ignored, do not descend)
- - parent-before-child ordering
- """
- for d in sorted_dirs(root):
- rel = d.relative_to(root)
-
- if rel == Path("."):
- continue
-
- # skip .git explicitly
- if d.name == ".git":
- continue
-
- # .gitignore filtering
- if ignore_tree.check(str(rel)) == "Ignore":
- continue
-
- yield d
-
-
-# ----------------------------------------------------------------------
-# actions
-# ----------------------------------------------------------------------
-
-def action_list(root, ignore_tree, mark_file, mode):
- """
- mode ∈ {"empty","marked","all"}
- """
- for d in visible_dirs(root, ignore_tree, mark_file):
- if mode == "all":
- print(d.relative_to(root))
- continue
-
- if mode == "marked":
- if has_mark(d, mark_file):
- print(d.relative_to(root))
- continue
-
- if mode == "empty":
- if is_empty_dir(d):
- print(d.relative_to(root))
- continue
-
-
-def action_mark(root, ignore_tree, mark_file, mode):
- """
- mode ∈ {"empty","all"}
- """
- for d in visible_dirs(root, ignore_tree, mark_file):
- if mode == "empty" and not is_empty_dir(d):
- continue
- try:
- (d / mark_file).touch(exist_ok=True)
- except Exception:
- pass
-
-
-def action_clean(root, ignore_tree, mark_file, mode):
- """
- mode ∈ {"nonempty","all"}
- """
- for d in visible_dirs(root, ignore_tree, mark_file):
- m = d / mark_file
- if not m.exists():
- continue
-
- if mode == "nonempty":
- if is_empty_dir(d):
- continue
-
- try:
- m.unlink()
- except Exception:
- pass
-
-
-# ----------------------------------------------------------------------
-# usage
-# ----------------------------------------------------------------------
-
-USAGE = """
-usage:
- git-empty-dir (list|mark|clean) [all|marked|empty] [file-<name>]
- git-empty-dir help
- git-empty-dir usage
-
-defaults:
- mark-file = .gitkeep
- ignores .git
- follows .gitignore (no descent into ignored dirs)
-
-examples:
- git-empty-dir list
- git-empty-dir list marked file-.githolder
- git-empty-dir mark
- git-empty-dir clean all
-"""
-
-
-# ----------------------------------------------------------------------
-# CLI
-# ----------------------------------------------------------------------
-
-def CLI(argv):
- if len(argv) == 0:
- print(USAGE)
- return 0
-
- cmd = argv[0]
-
- if cmd in ("help","usage"):
- print(USAGE)
- return 0
-
- # command
- if cmd not in ("list","mark","clean"):
- print(f"unknown command: {cmd}")
- print(USAGE)
- return 1
-
- # submode
- mode = None
- mark_file = ".gitkeep"
-
- for a in argv[1:]:
- if a.startswith("file-"):
- mark_file = a[5:]
- continue
-
- if a in ("all","empty","marked"):
- mode = a
- continue
-
- print(f"unknown argument: {a}")
- print(USAGE)
- return 1
-
- # defaults
- if cmd == "list":
- if mode is None:
- mode = "empty"
- elif cmd == "mark":
- if mode is None:
- mode = "empty"
- elif cmd == "clean":
- if mode is None:
- mode = "nonempty"
-
- root = Path(".").resolve()
- ignore_tree = load_gitignore_tree(root)
-
- if cmd == "list":
- action_list(root, ignore_tree, mark_file, mode)
-
- elif cmd == "mark":
- if mode == "all":
- action_mark(root, ignore_tree, mark_file, "all")
- else:
- action_mark(root, ignore_tree, mark_file, "empty")
-
- elif cmd == "clean":
- if mode == "all":
- action_clean(root, ignore_tree, mark_file, "all")
- else:
- action_clean(root, ignore_tree, mark_file, "nonempty")
-
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(CLI(sys.argv[1:]))
+++ /dev/null
-../source_sync/Harmony.py
\ No newline at end of file
+++ /dev/null
-../source_sync/load_command_module.py
\ No newline at end of file
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-meta.py - thin wrappers around command modules
-
-Current responsibilities:
- 1. Load the incommon 'printenv' command module (no .py extension)
- using load_command_module.load_command_module().
- 2. Expose printenv() here, calling the imported printenv() work
- function with default arguments (equivalent to running without
- any CLI arguments).
- 3. Provide a simple version printer for this meta module.
- 4. Provide a small debug tag API (set/clear/has).
-"""
-
-from __future__ import annotations
-
-import datetime
-from load_command_module import load_command_module
-
-
-# Load the incommon printenv module once at import time
-_PRINTENV_MODULE = load_command_module("printenv")
-_Z_MODULE = load_command_module("Z")
-
-
-# Meta module version
-_major = 1
-_minor = 1
-def version_print() -> None:
- """
- Print the meta module version as MAJOR.MINOR.
- """
- print(f"{_major}.{_minor}")
-
-
-# Debug tag set and helpers
-_debug = set([
-])
-
-
-def debug_set(tag: str) -> None:
- """
- Add a debug tag to the meta debug set.
- """
- _debug.add(tag)
-
-
-def debug_clear(tag: str) -> None:
- """
- Remove a debug tag from the meta debug set, if present.
- """
- _debug.discard(tag)
-
-
-def debug_has(tag: str) -> bool:
- """
- Return True if the given debug tag is present.
- """
- return tag in _debug
-
-
-# Touch the default tag once so static checkers do not complain about
-# unused helpers when imported purely for side-effects.
-debug_has("Command")
-
-
-def printenv() -> int:
- """
- Call the imported printenv() work function with default arguments:
- - no null termination
- - no newline quoting
- - no specific names (print full environment)
- - prog name 'printenv'
- """
- return _PRINTENV_MODULE.printenv(
- False # null_terminate
- ,False # quote_newlines
- ,[] # names
- ,"printenv"
- )
-
-
-def z_format_mtime(
- mtime: float
-) -> str:
- """
- Format a POSIX mtime (seconds since epoch, UTC) using the Z module.
-
- Uses Z.ISO8601_FORMAT and Z.make_timestamp(dt=...).
- """
- dt = datetime.datetime.fromtimestamp(mtime, datetime.timezone.utc)
- return _Z_MODULE.make_timestamp(
- fmt=_Z_MODULE.ISO8601_FORMAT
- ,dt=dt
- )
+++ /dev/null
-../source_sync/
\ No newline at end of file
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-A_minus_B - CLI test driver for skeleton.tree_dict_A_minus_B(A, B)
-
-Usage:
- A_minus_B <A_root> <B_root>
-"""
-
-from __future__ import annotations
-
-import os
-import sys
-from typing import Sequence
-
-import meta
-import skeleton
-
-
-def CLI(argv: Sequence[str] | None = None) -> int:
- if argv is None:
- argv = sys.argv[1:]
-
- prog = os.path.basename(sys.argv[0]) if sys.argv else "A_minus_B"
-
- if len(argv) != 2 or argv[0] in ("-h", "--help"):
- print(f"Usage: {prog} <A_root> <B_root>")
- return 1
-
- A_root = argv[0]
- B_root = argv[1]
-
- if not os.path.isdir(A_root):
- print(f"{prog}: {A_root}: not a directory")
- return 2
-
- if not os.path.isdir(B_root):
- print(f"{prog}: {B_root}: not a directory")
- return 3
-
- A = skeleton.tree_dict_make(A_root, None)
- B = skeleton.tree_dict_make(B_root, None)
-
- meta.debug_set("tree_dict_A_minus_B")
-
- _result = skeleton.tree_dict_A_minus_B(A, B)
-
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-CLI.py - Harmony skeleton checker
-
-Grammar (informal):
-
- check <command>* [<other>]
-
- <command> :: <help> | <no_other> | <has_other>
-
- <help> :: version | help | usage
- <no_other> :: environment
- <has_other> :: structure | import | export | suspicious | addendum | all
-
-Commands are sorted into three sets:
- 1. HELP_COMMANDS
- 2. NO_OTHER_COMMANDS
- 3. HAS_OTHER_COMMANDS
-
-At runtime, argv commands are classified into four lists:
- 1. help_list
- 2. no_other_list
- 3. has_other_list
- 4. unclassified_list
-
-If the meta debug set contains the tag "print_command_lists", these four lists
-are printed.
-
-If 'environment' appears in no_other_list, the meta.printenv() helper
-is invoked to print the environment.
-
-For <has_other> commands we compare:
-
- A = Harmony skeleton tree_dict
- B = <other> project tree_dict (path is the last argv token when any
- <has_other> is present before it).
-"""
-
-from __future__ import annotations
-
-import os
-import sys
-from typing import Sequence
-
-import command
-import doc
-import Harmony
-import meta
-import skeleton
-
-# meta.debug_set("print_command_lists")
-
-# Command tag sets (classification universe)
-HELP_COMMANDS: set[str] = set([
- "version"
- ,"help"
- ,"usage"
-])
-
-NO_OTHER_COMMANDS: set[str] = set([
- "environment"
-])
-
-HAS_OTHER_COMMANDS: set[str] = set([
- "structure"
- ,"import"
- ,"export"
- ,"suspicious"
- ,"addendum"
- ,"all"
-])
-
-
-def command_type(arg: str) -> str:
- """
- Classify a single command token.
-
- Returns:
- "Help" if arg is a help command
- "NoOther" if arg is a no_other command
- "HasOther" if arg is a has_other command
- "UnClassified" otherwise
- """
- if arg in HELP_COMMANDS:
- return "Help"
-
- if arg in NO_OTHER_COMMANDS:
- return "NoOther"
-
- if arg in HAS_OTHER_COMMANDS:
- return "HasOther"
-
- return "UnClassified"
-
-
-def print_command_lists(
- help_list: list[str]
- ,no_other_list: list[str]
- ,has_other_list: list[str]
- ,unclassified_list: list[str]
-) -> None:
- """
- Print the four classified command lists derived from argv.
- """
- print("help_list:", help_list)
- print("no_other_list:", no_other_list)
- print("has_other_list:", has_other_list)
- print("unclassified_list:", unclassified_list)
-
-
-def CLI(argv: Sequence[str] | None = None) -> int:
- """
- CLI entrypoint.
-
- Responsibilities:
- 1. Accept argv (or sys.argv[1:] by default).
- 2. Classify arguments using command_type(), with the last argument
- treated specially to avoid aliasing.
- 3. Invoke behaviors implied by the commands.
- 4. Return integer status code.
-
- Argument interpretation:
-
- Let argv = [a0, a1, ..., aN-1].
-
- - If N == 0:
- no commands; nothing to do.
-
- - If N >= 1:
- * Classify a0..aN-2.
- - If any are UnClassified -> error.
-
- * If any <has_other> appear in a0..aN-2:
- - aN-1 is treated as <other> path (B_root), not classified.
-
- * If no <has_other> appear in a0..aN-2:
- - Classify aN-1:
- - If UnClassified -> error (unknown command).
- - If HasOther -> error (other path not specified).
- - Else -> added to Help / NoOther lists.
- """
- if argv is None:
- argv = sys.argv[1:]
-
- # No arguments: print usage and exit with status 1.
- if len(argv) == 0:
- doc.print_usage()
- return 1
-
- # No arguments: nothing to do (could later decide to print usage).
- if len(argv) == 0:
- return 0
-
- # Split into head (all but last) and last argument
- head = argv[:-1]
- last = argv[-1]
-
- help_list: list[str] = []
- no_other_list: list[str] = []
- has_other_list: list[str] = []
- unclassified_list: list[str] = []
-
- # 1. Classify head tokens
- for arg in head:
- ct = command_type(arg)
-
- if ct == "Help":
- help_list.append(arg)
- elif ct == "NoOther":
- no_other_list.append(arg)
- elif ct == "HasOther":
- has_other_list.append(arg)
- else:
- unclassified_list.append(arg)
-
- # Any unclassified in the head is an error
- if len(unclassified_list) > 0:
- first_bad = unclassified_list[0]
- print(f"Unrecognized command: {first_bad}")
- return 5
-
- head_has_other = (len(has_other_list) > 0)
-
- B_root: str | None = None
-
- if head_has_other:
- # 2A. Any <has_other> in head -> last arg is always <other> path.
- B_root = os.path.abspath(last)
- else:
- # 2B. No <has_other> in head -> classify last.
- ct = command_type(last)
-
- if ct == "UnClassified":
- print(f"Unrecognized command: {last}")
- return 5
-
- if ct == "HasOther":
- print("Other path not specified for has_other command(s).")
- return 6
-
- if ct == "Help":
- help_list.append(last)
- elif ct == "NoOther":
- no_other_list.append(last)
- # ct cannot be HasOther here due to earlier check.
-
- if meta.debug_has("print_command_lists"):
- print_command_lists(
- help_list
- ,no_other_list
- ,has_other_list
- ,unclassified_list
- )
-
- # Help handling
- if len(help_list) > 0:
- if "version" in help_list:
- meta.version_print()
- if "usage" in help_list:
- doc.print_usage()
- if "help" in help_list:
- doc.print_help()
- return 1
-
- ret_val = 0
-
- # No-other commands (environment, etc.)
- if "environment" in no_other_list:
- env_status = meta.printenv()
- if env_status != 0:
- ret_val = env_status
-
- # If we still have no has_other commands, we are done.
- # (Example: just "environment", or just "help/usage".)
- if len(has_other_list) == 0:
- return ret_val
-
- # At this point we know:
- # - has_other_list is non-empty
- # - B_root must have been set (head_has_other was True)
- if B_root is None:
- print("Internal error: B_root not set despite has_other commands.")
- return 7
-
- if not os.path.isdir(B_root):
- print(f"Other project path is not a directory: {B_root}")
- return 4
-
- # Determine Harmony root (A_root)
- status, A_root = Harmony.where()
-
- if status == "not-found":
- print("Harmony project not found; normally this command is run from within Harmony.")
- return 3
-
- if status == "different":
- print("Seems we are not running in the Harmony project, will exit.")
- return 2
-
- # Build tree_dicts for A (Harmony) and B (other project)
- A_tree = skeleton.tree_dict_make(A_root, None)
- B_tree = skeleton.tree_dict_make(B_root, None)
-
- # Dispatch the <has_other> commands
- cmd_status = command.dispatch(
- has_other_list
- ,A_tree
- ,B_tree
- ,A_root
- ,B_root
- )
-
- if cmd_status != 0:
- ret_val = cmd_status
-
- return ret_val
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-GitIgnore.py - minimal .gitignore-based helper for Harmony projects
-
-Behavior:
-
- 1. During initialization, traverse the project tree rooted at
- <project_path>.
-
- 2. Whenever a directory contains a '.gitignore' file, record:
- - its relative directory path from the project root
- - a list of regular expressions compiled from the patterns
- in that '.gitignore' file
-
- These are stored in:
-
- self.rules: Dict[str, List[Pattern]]
-
- where the key is the directory RELATIVE to the project root:
- "" -> project root (top-level .gitignore)
- "src" -> src/.gitignore
- "src/module" -> src/module/.gitignore
-
- 3. check(<path>) -> token:
-
- - <path> is a path relative to the project root.
-
- - We compute all prefix directories of <path>, including the
- root (""), for example:
-
- path = "a/b/c.py"
- prefixes = ["", "a", "a/b"]
-
- - For each prefix, if there are regexes stored for that directory,
- we collect them.
-
- - We then test ALL collected regexes against the basename of
- <path> (the last component only).
-
- - If ANY regex matches, return 'Ignore'.
- Otherwise return 'Accept'.
-
-Notes:
-
- * We implement a simplified subset of .gitignore semantics suitable
- for your current patterns and add a small base ignore set for
- always-ignored names such as '.git'.
-"""
-
-from __future__ import annotations
-
-import fnmatch
-import os
-import re
-from typing import Dict, List
-import Harmony
-
-
-class GitIgnore:
- """
- GitIgnore(project_path)
-
- Attributes:
- project_path:
- Absolute path to the project root.
-
- rules:
- Mapping from relative directory path -> list of compiled regex
- patterns derived from that directory's '.gitignore' file.
-
- Example:
- rules[""] -> patterns from <root>/.gitignore
- rules["developer"] -> patterns from developer/.gitignore
-
- base_patterns:
- List of compiled regex patterns applied to the basename of every
- checked path, independent of any .gitignore file. Currently used
- to always ignore '.git' directories.
- """
-
- def __init__(
- self
- ,project_path: str
- ) -> None:
- """
- Initialize a GitIgnore instance with a path to a project and
- scan for '.gitignore' files.
- """
- self.project_path: str = os.path.abspath(project_path)
- self.rules: Dict[str, List[re.Pattern]] = {}
-
- # Base patterns: always applied, regardless of .gitignore contents.
- # These are matched against basenames only.
- self.base_patterns: List[re.Pattern] = [
- re.compile(r"^\.git$") # ignore any basename == ".git"
- ]
-
- self._scan_project()
-
- def _scan_project(self) -> None:
- """
- Traverse the project tree and populate self.rules with entries of
- the form:
-
- <rel_dir> -> [Pattern, Pattern, ...]
-
- where <rel_dir> is the directory containing '.gitignore', relative
- to the project root ("" for root).
- """
- root = self.project_path
-
- for dirpath, dirnames, filenames in os.walk(root, topdown=True):
- if ".gitignore" not in filenames:
- continue
-
- rel_dir = os.path.relpath(dirpath, root)
- if rel_dir == ".":
- rel_dir = ""
-
- gitignore_path = os.path.join(dirpath, ".gitignore")
- patterns = self._parse_gitignore_file(gitignore_path)
-
- if patterns:
- if rel_dir not in self.rules:
- self.rules[rel_dir] = []
- self.rules[rel_dir].extend(patterns)
-
- def _parse_gitignore_file(
- self
- ,gitignore_path: str
- ) -> List[re.Pattern]:
- """
- Parse a single '.gitignore' file into a list of compiled regex patterns.
-
- Simplified rules:
- - Blank lines and lines starting with '#' are ignored.
- - Lines containing '/' in the MIDDLE are currently ignored
- (future extension).
- - Lines ending with '/' are treated as directory name patterns:
- '__pycache__/' -> pattern on basename '__pycache__'
- - All patterns are treated as name globs and compiled via
- fnmatch.translate(), to be matched against basenames only.
- """
- patterns: List[re.Pattern] = []
-
- try:
- with open(gitignore_path, "r", encoding="utf-8") as f:
- for raw_line in f:
- line = raw_line.strip()
-
- # Skip comments and blank lines
- if not line or line.startswith("#"):
- continue
-
- # Remove trailing '/' for directory patterns (e.g. '__pycache__/')
- if line.endswith("/"):
- line = line[:-1].strip()
- if not line:
- continue
-
- # If there is still a '/' in the line, we do not support this
- # pattern in this minimal implementation.
- if "/" in line:
- continue
-
- # Compile as a name glob -> regex
- regex_text = fnmatch.translate(line)
- patterns.append(re.compile(regex_text))
-
- except OSError:
- # If the .gitignore cannot be read, just skip it.
- return patterns
-
- return patterns
-
- def check(
- self
- ,path: str
- ) -> str:
- """
- Check a path against the collected .gitignore patterns.
-
- path:
- A path relative to the project root.
-
- Returns:
- 'Ignore' if any applicable pattern matches the basename of the path,
- otherwise 'Accept'.
- """
- # Normalize the incoming path
- norm = os.path.normpath(path)
-
- # If the path is '.' or empty, we accept it
- if norm in ("", "."):
- return "Accept"
-
- basename = os.path.basename(norm)
-
- # First, apply base patterns (always applied).
- for pat in self.base_patterns:
- if pat.match(basename):
- return "Ignore"
-
- # Build the list of directories that may contribute .gitignore rules.
- #
- # For path "a/b/c":
- # prefixes: ["", "a", "a/b"]
- parts = norm.split(os.sep)
-
- prefixes: List[str] = [""]
- prefix = None
- for part in parts[:-1]:
- if prefix is None:
- prefix = part
- else:
- prefix = os.path.join(prefix, part)
- prefixes.append(prefix)
-
- # Collect all patterns from the applicable .gitignore directories
- for rel_dir in prefixes:
- dir_patterns = self.rules.get(rel_dir)
- if not dir_patterns:
- continue
-
- for pat in dir_patterns:
- if pat.match(basename):
- return "Ignore"
-
- return "Accept"
-
-
-def test_GitIgnore() -> int:
- """
- 1. Locate the Harmony project root using Harmony.where().
- 2. Create a GitIgnore instance rooted at that path.
- 3. Print:
- - directories that have .gitignore rules
- - directories (relative) that would be ignored by check()
- """
- status, Harmony_root = Harmony.where()
-
- if status == "not-found":
- print("Harmony project not found; cannot test GitIgnore.")
- return 1
-
- if status == "different":
- print("Warning: Harmony not found, using nearest .git directory for GitIgnore test.")
-
- gi = GitIgnore(Harmony_root)
-
- print(".gitignore rule directories (relative to Harmony root):")
- for rel_dir in sorted(gi.rules.keys()):
- print(f" {rel_dir if rel_dir else '.'}")
-
- print("\nDirectories that would be ignored (relative to Harmony root):")
- for dirpath, dirnames, filenames in os.walk(Harmony_root, topdown=True):
- rel_dir = os.path.relpath(dirpath, Harmony_root)
- if rel_dir == ".":
- rel_dir = ""
-
- if gi.check(rel_dir) == "Ignore":
- print(f" {rel_dir if rel_dir else '.'}")
-
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(test_GitIgnore())
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-locate the project root
-"""
-
-from __future__ import annotations
-
-import meta
-import os
-import sys
-from typing import Any, Callable, Dict
-
-# where
-#
-# Context / assumptions:
-# 1. This module lives somewhere under the Harmony tree, for example:
-# /.../Harmony/tool/skeleton/skeleton.py
-# 2. CLI.py is run from somewhere inside the same tree (or a clone).
-#
-# Search behavior:
-# 1. Start from the directory containing this file.
-# 2. Walk upward towards the filesystem root, with limits:
-# a) Do not move up more than 5 levels.
-# b) Stop immediately if the current directory contains a
-# '.git' subdirectory.
-#
-# Result classification:
-# status is one of:
-# 'found' -> we found a directory whose basename is 'Harmony'
-# 'different' -> we stopped at a directory that has a '.git'
-# subdirectory, but its basename is not 'Harmony'
-# 'not-found' -> we hit the 5-level limit or filesystem root
-# without finding 'Harmony' or a '.git' directory
-#
-# Path:
-# - In all cases, the returned path is the last directory inspected:
-# * the 'Harmony' directory (status 'found'), or
-# * the directory with '.git' (status 'different'), or
-# * the directory at the 5-level limit / filesystem root
-# (status 'not-found').
-#
-# Debug printing:
-# - If meta.debug_has("print_Harmony_root") is true, print:
-# * "The Harmony project root found at: {path}"
-# when status == 'found'
-# * "Harmony not found, but found: {path}"
-# when status == 'different'
-# * "Harmony not found."
-# when status == 'not-found'
-def where() -> tuple[str, str]:
- """
- Locate the Harmony root (or best guess).
-
- Returns:
- (status, path)
- """
- here = os.path.abspath(__file__)
- d = os.path.dirname(here)
-
- harmony_root = None
- status = "not-found"
-
- max_up = 5
- steps = 0
-
- while True:
- base = os.path.basename(d)
-
- # Case 1: exact 'Harmony' directory name
- if base == "Harmony":
- harmony_root = d
- status = "found"
- break
-
- # Case 2: stop at a directory that has a '.git' subdirectory
- git_dir = os.path.join(d, ".git")
- if os.path.isdir(git_dir):
- harmony_root = d
- if base == "Harmony":
- status = "found"
- else:
- status = "different"
- break
-
- parent = os.path.dirname(d)
-
- # Stop if we hit filesystem root
- if parent == d:
- harmony_root = d
- status = "not-found"
- break
-
- steps += 1
- if steps > max_up:
- # Reached search depth limit; last inspected directory is d
- harmony_root = d
- status = "not-found"
- break
-
- d = parent
-
- if harmony_root is None:
- # Extremely defensive; in practice harmony_root will be set above.
- harmony_root = d
-
- root_base = os.path.basename(harmony_root)
-
- # Warning to stderr if we are not literally in a 'Harmony' directory
- if root_base != "Harmony":
- sys.stderr.write(
- f"WARNING: Harmony root basename is '{root_base}', expected 'Harmony'.\n"
- )
-
- if meta.debug_has("print_Harmony_root"):
- if status == "found":
- print(f"The Harmony project root found at: {harmony_root}")
- elif status == "different":
- print(f"Harmony not found, but found: {harmony_root}")
- else:
- print("Harmony not found.")
-
- return status, harmony_root
-
-def test_where() -> int:
- """
- Simple test that prints the Harmony root using the debug flag.
- """
- meta.debug_set("print_Harmony_root")
- status, _root = where()
- return 0 if status != "not-found" else 1
-
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-Harmony_where - CLI to locate the Harmony project root
-
-Usage:
- Harmony_where
-
-Prints the status and path returned by Harmony.where().
-"""
-
-from __future__ import annotations
-
-import sys
-
-import Harmony
-
-
-def CLI(argv=None) -> int:
- # Ignore argv; no arguments expected
- status, Harmony_root = Harmony.where()
-
- if status == "found":
- print(f"Harmony project root found at: {Harmony_root}")
- return 0
-
- if status == "different":
- print(f"Harmony not found, but nearest .git directory is: {Harmony_root}")
- return 1
-
- print("Harmony project root not found.")
- return 2
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
+++ /dev/null
-#+TITLE: skeleton_compare – Harmony skeleton comparison tool
-#+AUTHOR: Reasoning Technology
-
-* 1. Overview
-
-1.1
-~skeleton_compare~ compares a Harmony skeleton (=A=) with a derived or legacy project (=B=).
-
-1.2
-It answers:
-
-- How has B diverged from A?
-- What should be imported back into A?
-- What should be exported from A into B?
-- Which nodes are misplaced or suspicious?
-- Which nodes represent valid project-specific extensions?
-
-1.3
-The entrypoint in this project is the symlink:
-
-- =tool/skeleton_compaare=
-
-which points to:
-
-- =tool/skeleton_compare_source/CLI.py=
-
-* 2. Role in the Harmony ecosystem
-
-2.1
-Harmony defines a skeleton layout (directories, leaves, extension points).
-
-2.2
-Projects are expected to:
-
-- start from that skeleton
-- add work under approved extension points
-- keep core structure aligned over time
-
-2.3
-Reality diverges:
-
-- legacy projects that predate Harmony
-- projects with ad-hoc edits in skeleton areas
-- skeleton evolution over months or years
-
-2.4
-~skeleton_compare~ provides:
-
-- a structural comparison
-- a semantic comparison (types, topology)
-- a chronological comparison (mtimes)
-- actionable commands to re-align projects
-
-* 3. High-level behavior
-
-3.1
-Tree construction
-
-1. Build =tree_dict= for A (Harmony skeleton).
-2. Build =tree_dict= for B (other project).
-3. Attach metadata per relative path:
-
- - =node_type= :: =directory= | =file= | =other= | =constrained=
- - =dir_info= :: =root= | =branch= | =leaf= | =NA=
- - =mtime= :: float seconds since epoch
-
-3.2
-Git ignore
-
-1. A simplified =.gitignore= model is applied.
-2. Some paths (e.g., =.git=) are always ignored.
-3. Only paths admitted by this model participate in comparisons.
-
-3.3
-Topology classification (relative to A)
-
-1. =in_between= :: under a directory in A, but not under any leaf in A.
-2. =below= :: under a leaf directory in A.
-3. Neither :: not under any directory known to A (ignored for most commands).
-
-3.4
-Chronological classification
-
-1. newer(B,A) :: B node has a newer mtime than A at the same path.
-2. older(B,A) :: B node has an older mtime than A at the same path.
-3. A-only :: path exists in A but not B.
-4. B-only :: path exists in B but not A.
-
-* 4. Command surface (conceptual)
-
-4.1
-~structure~
-
-1. Compares directory topology.
-2. Reports directories that:
-
- - exist as directories in A
- - are missing or non-directories in B
-
-3. Intended use:
-
- - detect missing branches in projects
- - detect structural drift
-
-4.2
-~import~
-
-1. Direction: B → A.
-2. Only considers:
-
- - nodes in the =in_between= region of B
- - that are new or absent in A
-
-3. Outputs:
-
- - ~mkdir -p~ commands (when needed)
- - ~cp --parents -a~ commands for files
- - a comment list for nodes that cannot be handled automatically
- (type mismatches, non-file/dir, constrained nodes)
-
-4. Intended use:
-
- - mine “good ideas” in B that belong in the skeleton
- - keep Harmony evolving based on real projects
-
-4.3
-~export~
-
-1. Direction: A → B.
-2. Considers:
-
- - A-only nodes (present in A, missing in B)
- - nodes where A’s file is newer than B’s file
-
-3. Outputs:
-
- - ~mkdir -p~ commands for B
- - ~cp --parents -a~ commands for files
-
-4. Intended use:
-
- - bring B back into alignment with the current Harmony skeleton
- - propagate skeleton fixes and improvements into projects
-
-4.4
-~suspicious~
-
-1. Reports nodes in B that are:
-
- - inside A’s directory structure
- - but not under any leaf directory
-
-2. Intended use:
-
- - highlight questionable placements
- - identify candidates for new skeleton structure
- - catch misuse of the skeleton (work living in the “framework” layer)
-
-4.5
-~addendum~
-
-1. Reports nodes in B that are:
-
- - under leaf directories in A
-
-2. Intended use:
-
- - show work added at the intended extension points
- - give a quick outline of “project-specific” content layered on Harmony
-
-4.6
-~all~
-
-1. Runs:
-
- - =structure=
- - =import=
- - =export=
- - =suspicious=
- - =addendum=
-
-2. Intended use:
-
- - periodic health check of a project against Harmony
- - initial analysis when inheriting an old project
-
-* 5. Safety and behavior guarantees
-
-5.1
-No direct modification
-
-1. ~skeleton_compaare~ itself does not modify either tree.
-2. It only prints suggested shell commands.
-3. A human is expected to review and run those commands (or not).
-
-5.2
-Constrained and unknown nodes
-
-1. Some paths are “constrained”:
-
- - object exists but metadata (e.g., ~mtime~) cannot be safely read
- - typical for special files or broken links
-
-2. These are:
-
- - classified as =constrained=
- - never touched by import/export logic
- - surfaced in “not handled automatically” lists
-
-5.3
-Robust to legacy layouts
-
-1. A and B are assumed to be non-overlapping roots.
-2. B does not have to be a clean Harmony derivative.
-3. The tool is designed to:
-
- - tolerate missing branches
- - tolerate ad-hoc additions
- - still classify and report differences coherently
-
-* 6. How to run it
-
-6.1
-From inside the Harmony repo:
-
-#+begin_src sh
-cd /path/to/Harmony
-tool/skeleton_compaare help
-tool/skeleton_compaare usage
-tool/skeleton_compaare structure ../SomeProject
-tool/skeleton_compaare all ../Rabbit
-#+end_src
-
-6.2
-The CLI help (from ~doc.py~) is the canonical reference for:
-
-1. grammar and argument rules
-2. meaning of A and B
-3. exact semantics of each command
-
-This =.org= file is a conceptual overview for Harmony toolsmiths and administrators.
-
-* 7. Maintenance notes
-
-7.1
-Core modules
-
-1. =skeleton_compare_source/skeleton.py=
- - tree construction
- - topology classification
- - “newer/older” logic
- - in-between / below partitioning
-
-2. =skeleton_compare_source/command.py=
- - high-level command semantics
- - import/export planning and printing
-
-3. =skeleton_compare_source/CLI.py=
- - argument classification
- - environment checks
- - dispatch to command handlers
-
-7.2
-Change discipline
-
-1. CLI behavior and text should be updated in:
-
- - =doc.py= (help/usage text)
- - this =.org= file (conceptual intent)
-
-2. Any behavioral change that affects:
-
- - classification rules
- - import/export semantics
- - constrained handling
-
- should be reflected here in section 3 or 4.
-
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-command.py - high-level dispatch for <has_other> Harmony check commands
-
-Commands (semantics):
-
- structure:
- - Differences in directory structure: directories present in A but
- not present as directories in B.
-
- import:
- - Shell copy commands to copy:
- * in-between nodes in B that are newer than A (same relative path), or
- * in-between nodes in B that do not exist in A at all.
- Direction: B -> A
- Also emits:
- * a mkdir list (directories to create in A)
- * an "other" list for type mismatches / non-file/dir nodes.
-
- export:
- - Shell copy commands to copy:
- * nodes in A that are newer than B, and
- * nodes in A that do not exist in B.
- Direction: A -> B
- Also emits:
- * a mkdir list (directories to create in B)
- * an "other" list for type mismatches / non-file/dir nodes.
-
- suspicious:
- - Nodes in B that fall "in between" the Harmony skeleton topology:
- under some A directory, but not under any A leaf directory.
- (tree_dict_in_between_and_below(A,B).in_between)
-
- addendum:
- - Nodes in B that fall "below" Harmony leaf directories:
- added work in appropriate extension locations.
- (tree_dict_in_between_and_below(A,B).below)
-
- all:
- - Runs structure, import, export, suspicious, and addendum.
-"""
-
-from __future__ import annotations
-
-import os
-from typing import Any, Dict, List, Tuple
-
-import skeleton
-
-TreeDict = Dict[str, Dict[str, Any]]
-
-
-def shell_quote(
- s: str
-) -> str:
- """
- Minimal single-quote shell quoting.
- """
- return "'" + s.replace("'", "'\"'\"'") + "'"
-
-
-def _print_header(
- title: str
-) -> None:
- print()
- print(f"== {title} ==")
-
-
-# ----------------------------------------------------------------------
-# structure: directories in A that are missing / non-directories in B
-# ----------------------------------------------------------------------
-def cmd_structure(
- A: TreeDict
- ,B: TreeDict
-) -> int:
- """
- structure: differences in directory structure, directories in A - B.
-
- We include any path where:
- - A[path].node_type == 'directory', and
- - either path not in B, or B[path].node_type != 'directory'.
- """
- structural: TreeDict = {}
-
- for path, info_A in A.items():
- if info_A.get("node_type") != "directory":
- continue
-
- info_B = B.get(path)
- if info_B is None or info_B.get("node_type") != "directory":
- structural[path] = info_A
-
- if not structural:
- _print_header("structure")
- print("No structural directory differences (A - B).")
- return 0
-
- _print_header("structure: directories in A not in B")
- skeleton.tree_dict_print(structural)
- return 0
-
-
-# ----------------------------------------------------------------------
-# import: B -> A (mkdir, cp, and "other" list), using in_between_newer
-# ----------------------------------------------------------------------
-
-
-def build_import_commands(
- A_tree: TreeDict
- ,B_tree: TreeDict
- ,A_root: str
- ,B_root: str
-) -> Tuple[List[str], List[str], List[str]]:
- """
- Compute shell commands to update A from B.
-
- Returns:
- (mkdir_cmds, cp_cmds, other_list)
-
- Semantics:
-
- mkdir_cmds:
- - Directories that are directories in B, but are missing in A.
- - We DO NOT auto-resolve type mismatches (e.g. B=directory,
- A=file); those go into other_list.
-
- cp_cmds:
- - Files where:
- * the path does not exist in A, OR
- * the node in A is not a file, OR
- * the B copy is newer than A (mtime comparison).
- - However, if A has a non-file at that path, we treat it as a
- type mismatch and add that path to other_list instead of
- emitting a cp command.
-
- other_list:
- - Human-readable notes for:
- * type mismatches between A and B, and
- * nodes in B that are neither 'file' nor 'directory'.
- """
- mkdir_cmds: List[str] = []
- cp_cmds: List[str] = []
- other_list: List[str] = []
-
- for rel_path, b_info in B_tree.items():
- b_type = b_info.get("node_type")
- rel_display = rel_path if rel_path else "."
-
- a_info = A_tree.get(rel_path)
- a_type = a_info.get("node_type") if a_info is not None else "MISSING"
-
- # Case 1: B node is neither file nor directory -> other_list
- if b_type not in ("file", "directory"):
- other_list.append(
- f"{rel_display}: A={a_type}, B={b_type}"
- )
- continue
-
- # Case 2: B directory
- if b_type == "directory":
- if a_info is None:
- # Missing in A: copy the directory recursively.
- src = os.path.join(B_root, rel_path) if rel_path else B_root
- # The destination should be the parent directory in A.
- # os.path.join(A_root, rel_path) gives the full path to the new directory in A.
- # os.path.dirname of that gives the parent directory.
- dst = os.path.dirname(os.path.join(A_root, rel_path))
- mkdir_cmds.append(
- f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
- )
- else:
- # Exists in A: must also be a directory to be "structurally OK"
- if a_type != "directory":
- # Type mismatch: do not mkdir, just report
- other_list.append(
- f"{rel_display}: A={a_type}, B=directory"
- )
- continue
-
- # Case 3: B file
- # Decide whether to copy B -> A, or report conflict.
- if a_info is None:
- # B-only file
- src = os.path.join(B_root, rel_path) if rel_path else B_root
- # Destination is the parent directory in A, with a trailing slash
- dst = os.path.dirname(os.path.join(A_root, rel_path))
- cp_cmds.append(
- f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
- )
- continue
-
- # A has something at this path
- if a_type != "file":
- # Type mismatch (e.g. A=directory, B=file, or A=other)
- other_list.append(
- f"{rel_display}: A={a_type}, B=file"
- )
- continue
-
- # Both files: compare mtime
- a_mtime = a_info.get("mtime")
- b_mtime = b_info.get("mtime")
-
- if isinstance(a_mtime, (int, float)) and isinstance(b_mtime, (int, float)):
- if b_mtime > a_mtime:
- src = os.path.join(B_root, rel_path) if rel_path else B_root
- # Destination is the parent directory in A, with a trailing slash
- dst = os.path.dirname(os.path.join(A_root, rel_path))
- cp_cmds.append(
- f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
- )
-
- return mkdir_cmds, cp_cmds, other_list
-
-def cmd_import(
- A_tree: TreeDict
- ,B_tree: TreeDict
- ,A_root: str
- ,B_root: str
-) -> int:
- """
- import: update the skeleton (A) from the project (B),
- using only in_between_newer nodes.
- """
- inb_newer = skeleton.in_between_newer(A_tree, B_tree)
-
- mkdir_cmds, cp_cmds, other_list = build_import_commands(
- A_tree
- ,inb_newer
- ,A_root
- ,B_root
- )
-
- print("== import: copy from B -> A (in-between newer only) ==")
- print(f"# A root: {A_root}")
- print(f"# B root: {B_root}")
- print("# Only considering in-between files that are new or absent in A.")
- print("#")
-
- print("# Directories to copy from B -> A (cp -a):")
- if mkdir_cmds:
- for line in mkdir_cmds:
- print(line)
- else:
- print("# (none)")
- print("#")
-
- print("# Files to copy from B -> A (cp -a):")
- if cp_cmds:
- for line in cp_cmds:
- print(line)
- else:
- print("# (none)")
- print("#")
-
- print("# Nodes NOT handled automatically (type mismatches / non-file/dir):")
- if other_list:
- for rel in other_list:
- print(f"# {rel}")
- else:
- print("# (none)")
-
- return 0
-
-
-# ----------------------------------------------------------------------
-# export: A -> B (mkdir, cp, and "other" list)
-# ----------------------------------------------------------------------
-def build_export_commands(
- A_tree: TreeDict
- ,B_tree: TreeDict
- ,A_root: str
- ,B_root: str
-) -> Tuple[List[str], List[str], List[str]]:
- """
- Compute shell commands to update B from A.
-
- Returns:
- (mkdir_cmds, cp_cmds, other_list)
-
- Semantics:
-
- mkdir_cmds:
- - Directories that are directories in A, but are missing in B.
- - Type mismatches go into other_list.
-
- cp_cmds:
- - Files where:
- * the path does not exist in B, OR
- * the node in B is not a file, OR
- * the A copy is newer than B (mtime comparison).
- - If B has a non-file while A has a file, treat as type mismatch.
-
- other_list:
- - Human-readable notes for:
- * type mismatches between A and B, and
- * nodes in A that are neither 'file' nor 'directory'.
- """
- mkdir_cmds: List[str] = []
- cp_cmds: List[str] = []
- other_list: List[str] = []
-
- # Sort keys to ensure parent directories are processed before their children.
- sorted_paths = sorted(A_tree.keys(), key=len)
- included_dirs: Set[str] = set()
-
- for rel_path in sorted_paths:
- a_info = A_tree[rel_path]
- a_type = a_info.get("node_type")
- rel_display = rel_path if rel_path else "."
-
- b_info = B_tree.get(rel_path)
- b_type = b_info.get("node_type") if b_info is not None else "MISSING"
-
- # Case 1: A node is neither file nor directory -> other_list
- if a_type not in ("file", "directory"):
- other_list.append(
- f"{rel_display}: A={a_type}, B={b_type}"
- )
- continue
-
- # Check if this path is a child of an already included directory
- is_child_of_included_dir = False
- for d in included_dirs:
- if rel_path.startswith(d + os.sep):
- is_child_of_included_dir = True
- break
-
- if is_child_of_included_dir:
- continue
-
- # Case 2: A directory
- if a_type == "directory":
- if b_info is None:
- # Missing in B: copy the directory recursively.
- src = os.path.join(A_root, rel_path) if rel_path else A_root
- # The destination should be the parent directory in B.
- dst = os.path.dirname(os.path.join(B_root, rel_path))
- mkdir_cmds.append(
- f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
- )
- included_dirs.add(rel_path)
- else:
- # Exists in B: must also be directory
- if b_type != "directory":
- other_list.append(
- f"{rel_display}: A=directory, B={b_type}"
- )
- continue
-
- # Case 3: A file
- if b_info is None:
- # A-only file
- src = os.path.join(A_root, rel_path) if rel_path else A_root
- # Destination is the parent directory in B, with a trailing slash
- dst = os.path.dirname(os.path.join(B_root, rel_path))
- cp_cmds.append(
- f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
- )
- continue
-
- if b_type != "file":
- other_list.append(
- f"{rel_display}: A=file, B={b_type}"
- )
- continue
-
- # Both files: compare mtime
- a_mtime = a_info.get("mtime")
- b_mtime = b_info.get("mtime")
-
- if isinstance(a_mtime, (int, float)) and isinstance(b_mtime, (int, float)):
- if a_mtime > b_mtime:
- src = os.path.join(A_root, rel_path) if rel_path else A_root
- # Destination is the parent directory in B, with a trailing slash
- dst = os.path.dirname(os.path.join(B_root, rel_path))
- cp_cmds.append(
- f"cp -a {shell_quote(src)} {shell_quote(dst + os.sep)}"
- )
-
- return mkdir_cmds, cp_cmds, other_list
-
-
-def cmd_export(
- A_tree: TreeDict
- ,B_tree: TreeDict
- ,A_root: str
- ,B_root: str
-) -> int:
- """
- export: show directory creation and copy commands A -> B.
- """
- mkdir_cmds, cp_cmds, other_list = build_export_commands(
- A_tree
- ,B_tree
- ,A_root
- ,B_root
- )
-
- print("== export: copy from A -> B ==")
- print(f"# A root: {A_root}")
- print(f"# B root: {B_root}")
- print("#")
-
- print("# Directories to copy from A -> B (cp -a):")
- if mkdir_cmds:
- for line in mkdir_cmds:
- print(line)
- else:
- print("# (none)")
- print("#")
-
- print("# Files to copy from A -> B (cp -a):")
- if cp_cmds:
- for line in cp_cmds:
- print(line)
- else:
- print("# (none)")
- print("#")
-
- print("# Nodes NOT handled automatically (type mismatches / non-file/dir):")
- if other_list:
- for rel in other_list:
- print(f"# {rel}")
- else:
- print("# (none)")
-
- return 0
-
-
-# ----------------------------------------------------------------------
-# suspicious / addendum via in_between_and_below
-# ----------------------------------------------------------------------
-def cmd_suspicious(
- A: TreeDict
- ,B: TreeDict
-) -> int:
- """
- suspicious: nodes in B that fall 'in between' the Harmony skeleton,
- not under leaf directories.
- """
- in_between, _below = skeleton.tree_dict_in_between_and_below(A, B)
-
- _print_header("suspicious: nodes in-between Harmony leaves")
-
- if not in_between:
- print("No suspicious nodes found in B (relative to A).")
- return 0
-
- skeleton.tree_dict_print(in_between)
- return 0
-
-
-def cmd_addendum(
- A: TreeDict
- ,B: TreeDict
-) -> int:
- """
- addendum: nodes in B that fall 'below' Harmony leaf directories.
- """
- _in_between, below = skeleton.tree_dict_in_between_and_below(A, B)
-
- _print_header("addendum: nodes added under Harmony leaves")
-
- if not below:
- print("No addendum nodes found in B (relative to A).")
- return 0
-
- skeleton.tree_dict_print(below)
- return 0
-
-
-# ----------------------------------------------------------------------
-# Top-level dispatcher
-# ----------------------------------------------------------------------
-def dispatch(
- has_other_list: List[str]
- ,A: TreeDict
- ,B: TreeDict
- ,A_root: str
- ,B_root: str
-) -> int:
- """
- Dispatch <has_other> commands.
-
- has_other_list:
- List of command tokens (subset of:
- 'structure', 'import', 'export', 'suspicious', 'addendum', 'all').
-
- A, B:
- tree_dicts for Harmony skeleton (A) and <other> project (B).
-
- A_root, B_root:
- Root paths corresponding to A and B (for copy commands).
- """
- cmds = set(has_other_list)
-
- if "all" in cmds:
- cmds.update([
- "structure"
- ,"import"
- ,"export"
- ,"suspicious"
- ,"addendum"
- ])
-
- ordered = [
- "structure"
- ,"import"
- ,"export"
- ,"suspicious"
- ,"addendum"
- ]
-
- status = 0
-
- for name in ordered:
- if name not in cmds:
- continue
-
- if name == "structure":
- rc = cmd_structure(A, B)
- elif name == "import":
- rc = cmd_import(A, B, A_root, B_root)
- elif name == "export":
- rc = cmd_export(A, B, A_root, B_root)
- elif name == "suspicious":
- rc = cmd_suspicious(A, B)
- elif name == "addendum":
- rc = cmd_addendum(A, B)
- else:
- rc = 0
-
- if rc != 0:
- status = rc
-
- return status
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-doc.py - usage and help text for the Harmony 'check' tool
-
-Grammar (informal):
-
- <prog> <command>* [<other>]
-
- <command> :: <help> | <no_other> | <has_other>
-
- <help> :: version | help | usage
- <no_other> :: environment
- <has_other> :: structure | import | export | suspicious | addendum | all
-"""
-
-from __future__ import annotations
-
-import meta
-import os
-import sys
-from typing import TextIO
-
-
-def prog_name() -> str:
- """
- Return the program name as invoked by the user.
-
- Typically:
- - basename(sys.argv[0]) when running from the shell.
- - Falls back to 'check' if argv[0] is empty.
- """
- raw = sys.argv[0] if sys.argv and sys.argv[0] else "check"
- base = os.path.basename(raw) or raw
- return base
-
-
-def _usage_text(prog: str) -> str:
- return f"""\
-Usage:
- {prog} <command>* [<other>]
-
-Where:
- <command> :: <help> | <no_other> | <has_other>
-
- <help> :: version | help | usage
- <no_other> :: environment
- <has_other> :: structure | import | export | suspicious | addendum | all
-"""
-
-def _help_text(prog: str) -> str:
- return f"""\
-{prog} - Harmony skeleton integrity and metadata checker
-
-Syntax:
- {prog} <command>* [<other>]
-
-Where:
- <other> :: path
- <command> :: <help> | <no_other> | <has_other>
-
- <help> :: version | help | usage
- <no_other> :: environment
- <has_other> :: structure | import | export | suspicious | addendum | all
-
-Argument rules (informal):
- 1. <help> commands are processed first, and then the program returns.
- Hence if any help commands are present, the remaining commands
- are ignored.
-
- 2. We assume {prog} is run within the Harmony skeleton, or a skeleton
- derived directly from it. This is the 'default skeleton', or simply 'A'.
-
- 3. The <other> path is the directory of a project that is assumed to
- be built upon the default skeleton. This second project root is
- called 'B'.
-
- 4. If none of the commands require an <other> path, then <other>
- must not be given. If at least one command requires <other>, then
- <other> is required. Commands that require a path are called
- <has_other> commands.
-
- 5. Implementation detail:
- All arguments except the final one are interpreted strictly as
- command tokens. If any of those are <has_other>, the final argument
- is taken as <other>. If none of the earlier tokens are <has_other>,
- the final argument is also treated as a command token.
-
-Roots:
- A = Skeleton project root (auto-detected). Usually the Harmony skeleton.
- B = <other> project root (supplied when required).
-
-{prog} compares A with B. Differences may come from:
- - edits to the skeleton itself,
- - edits to skeleton files inside B,
- - or new files/directories added to B.
-Conceptually, A and B are any two non-overlapping directory trees.
-
-Command semantics:
- structure
- - Report directory-structure differences:
- directories present in A that are missing in B or not
- directories in B.
- - Output: table of such directories.
-
- import
- - Update A from B using only "in-between newer" files:
- * files in B that lie in the 'in-between' region relative to A, and
- * are newer than A or absent from A.
- - Also emits:
- * directories to create in A,
- * files to copy (B -> A),
- * nodes that cannot be handled automatically (type mismatches,
- constrained nodes, non-file/dir nodes).
- - Direction: B -> A
-
- export
- - Update B from A:
- * files in A newer than B at the same path,
- * files present in A but missing in B.
- - Also emits:
- * directories to create in B,
- * files to copy (A -> B),
- * nodes that cannot be handled automatically.
- - Direction: A -> B
-
- suspicious
- - Report B nodes that lie "in-between" Harmony leaves:
- under a directory from A, but not under any leaf directory of A.
- - Indicates questionable placements or missing skeleton structure.
-
- addendum
- - Report B nodes located "below" Harmony leaf directories:
- project-specific additions placed in proper extension points.
-
- all
- - Run: structure, import, export, suspicious, addendum (in that order).
-
-Notes:
- - tree_dict traversal respects a simplified .gitignore model plus
- always-ignored patterns (e.g. '.git').
- - Timestamps are formatted via the Z helper in UTC (ISO 8601).
-"""
-
-def print_usage(
- stream: TextIO | None = None
-) -> None:
- """
- Print the usage text to the given stream (default: sys.stdout),
- using the actual program name as invoked.
- """
- if stream is None:
- stream = sys.stdout
-
- text = _usage_text(prog_name())
- stream.write(text)
- if not text.endswith("\n"):
- stream.write("\n")
-
-
-def print_help(
- stream: TextIO | None = None
-) -> None:
- """
- Print the help text to the given stream (default: sys.stdout),
- using the actual program name as invoked.
- """
- if stream is None:
- stream = sys.stdout
-
- utext = _usage_text(prog_name())
- htext = _help_text(prog_name())
-
- stream.write(utext)
- if not utext.endswith("\n"):
- stream.write("\n")
-
- stream.write("\n")
- stream.write(htext)
- if not htext.endswith("\n"):
- stream.write("\n")
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-in_between_and_below - CLI test driver for skeleton.tree_dict_in_between_and_below(A, B)
-
-Usage:
- in_between_and_below <A_root> <B_root>
-"""
-
-from __future__ import annotations
-
-import os
-import sys
-from typing import Sequence
-
-import meta
-import skeleton
-
-
-def CLI(argv: Sequence[str] | None = None) -> int:
- if argv is None:
- argv = sys.argv[1:]
-
- prog = os.path.basename(sys.argv[0]) if sys.argv else "in_between_and_below"
-
- if len(argv) != 2 or argv[0] in ("-h", "--help"):
- print(f"Usage: {prog} <A_root> <B_root>")
- return 1
-
- A_root = argv[0]
- B_root = argv[1]
-
- if not os.path.isdir(A_root):
- print(f"{prog}: {A_root}: not a directory")
- return 2
-
- if not os.path.isdir(B_root):
- print(f"{prog}: {B_root}: not a directory")
- return 3
-
- A = skeleton.tree_dict_make(A_root, None)
- B = skeleton.tree_dict_make(B_root, None)
-
- meta.debug_set("tree_dict_in_between_and_below")
-
- _result = skeleton.tree_dict_in_between_and_below(A, B)
-
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-load_command_module.py - locate and import Python command modules from $PATH
-
-Behavior:
- 1. Search $PATH for an executable with the given command name.
- 2. Prefer a path containing '/incommon/'.
- 3. If only /usr/bin/<command> is found, raise an error saying we were
- looking for the incommon version.
- 4. Import the chosen script as a Python module, even if it has no .py
- extension, by forcing a SourceFileLoader.
-"""
-
-from __future__ import annotations
-
-import importlib.util
-import os
-from importlib.machinery import SourceFileLoader
-from types import ModuleType
-from typing import List
-
-
-def _find_command_candidates(command_name: str) -> List[str]:
- """
- Return a list of absolute paths to executables named `command_name`
- found on $PATH.
- """
- paths: list[str] = []
-
- path_env = os.environ.get("PATH", "")
- for dir_path in path_env.split(os.pathsep):
- if not dir_path:
- continue
- candidate = os.path.join(dir_path, command_name)
- if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
- paths.append(os.path.realpath(candidate))
-
- return paths
-
-
-def load_command_module(command_name: str) -> ModuleType:
- """
- Locate an executable named `command_name` on $PATH and load it
- as a Python module.
-
- Selection policy:
- 1. Prefer any path containing '/incommon/'.
- 2. If only /usr/bin/<command_name> candidates exist, raise an error
- saying we were looking for the incommon version.
- 3. If no candidate is found, raise an error.
-
- Implementation detail:
- Because the incommon command may lack a .py suffix, we explicitly
- construct a SourceFileLoader rather than relying on the default
- extension-based loader resolution.
- """
- candidates = _find_command_candidates(command_name)
-
- incommon_candidates = [
- p
- for p in candidates
- if "/incommon/" in p
- ]
-
- usrbin_candidates = [
- p
- for p in candidates
- if p.startswith("/usr/bin/")
- ]
-
- if incommon_candidates:
- target = incommon_candidates[0]
- elif usrbin_candidates:
- raise RuntimeError(
- f"Found /usr/bin/{command_name}, but expected the incommon Python "
- f"{command_name} module on PATH."
- )
- else:
- raise RuntimeError(
- f"Could not find an incommon '{command_name}' module on PATH."
- )
-
- module_name = f"rt_incommon_{command_name}"
-
- loader = SourceFileLoader(
- module_name
- ,target
- )
- spec = importlib.util.spec_from_loader(
- module_name
- ,loader
- )
- if spec is None:
- raise RuntimeError(f"Failed to create spec for {command_name} from {target}")
-
- module = importlib.util.module_from_spec(spec)
- # spec.loader is the SourceFileLoader we just created
- assert spec.loader is not None
- spec.loader.exec_module(module)
-
- return module
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-skeleton_test - build and print the Harmony tree_dict
-
-Usage:
- skeleton_test
-
-Behavior:
- 1. Locate the Harmony project root via Harmony.where().
- 2. Enable 'tree_dict_print' debug flag.
- 3. Call skeleton.tree_dict_make(Harmony_root, None).
-
-The skeleton.tree_dict_make() function is expected to call
-tree_dict_print() when the 'tree_dict_print' debug flag is set.
-"""
-
-from __future__ import annotations
-
-import sys
-
-import Harmony
-import meta
-import skeleton
-
-
-def CLI(argv=None) -> int:
- # No arguments expected
- status, Harmony_root = Harmony.where()
-
- if status == "not-found":
- print("Harmony project not found; cannot build tree_dict.")
- return 1
-
- if status == "different":
- print("Warning: Harmony not found, using nearest .git directory for tree_dict.")
-
- # Enable printing inside tree_dict_make
- meta.debug_set("tree_dict_print")
-
- _tree = skeleton.tree_dict_make(Harmony_root, None)
-
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
+++ /dev/null
-Conversation with Manus\r
-Started on November 20, 2025 at 08:56 PM Singapore time +08 (GMT+0800)\r
-\r
----\r
-\r
-08:56 PM | Thomas Lynch: Hello, I signed up today with 7 days free. However, in the middle of a chat, I was prompted to sign up, which I already did. It then charged me for the month, but we have not been 7 days yet, and it is full charge. Why did it do that?
- As a second question is the 'credits'. Did it prompt again because the credits were used? Am I now on the hook for two months of charges in less than a day of use?
- If just a few hours use uses up a 'month' of credits, then I really can't afford Manus, and need to back out of this.\r
-\r
-08:56 PM | Manus AI: I understand your concern about the unexpected charge! Let me clarify what likely happened.
- About the charge: Our subscriptions operate on a credit-based system (http://help.manus.im/en/articles/11711097-what-are-the-rules-for-credits-consumption-and-how-can-i-obtain-them) where credits are consumed during active task processing. When you run out of credits (even during a trial), you may be prompted to upgrade to continue using the service, which would trigger billing.
- You're not charged twice: The billing is monthly from your subscription date (http://help.manus.im/en/articles/11711089-where-can-i-find-the-invoice-after-purchase-can-the-invoice-be-modified), so you'll only have one active subscription.
- To check your usage: Visit Settings > Usage to see exactly how your credits were consumed and review transaction details.
- Your options:
- - Cancel subscription: (http://help.manus.im/en/articles/11711087-how-can-i-cancel-my-subscription) Go to your profile > Account > Manage subscription > Cancel subscription to stop future charges.
-- Request refund: You can submit a refund request with your invoice information and reason. We'll review it according to our refund policy.
- The good news is you can cancel anytime to prevent future charges, and we do consider refund requests on a case-by-case basis!
-
-Sources:
-What are the rules for credits consumption? And how can I obtain them? (http://help.manus.im/en/articles/11711097-what-are-the-rules-for-credits-consumption-and-how-can-i-obtain-them)
-Where can I check my credit consumption/usage? (http://help.manus.im/en/articles/12520235-where-can-i-check-my-credit-consumption-usage)
-How can I request a refund for a purchased membership? (http://help.manus.im/en/articles/11711102-how-can-i-request-a-refund-for-a-purchased-membership)
-How can I cancel my subscription? (http://help.manus.im/en/articles/11711087-how-can-i-cancel-my-subscription)
-Where can I find the invoice after purchase, can the invoice be modified? (http://help.manus.im/en/articles/11711089-where-can-i-find-the-invoice-after-purchase-can-the-invoice-be-modified)\r
-\r
-08:57 PM | Liora from Manus: We will respond to you with the resolution within 48 hours. Please wait for our update!\r
-\r
-09:01 PM | Thomas Lynch: You charged my $46.80, the plan says it costs $33.00 explain.\r
-\r
-09:02 PM | Thomas Lynch: hello?\r
-\r
----\r
-Exported from Manus on November 20, 2025 at 09:03 PM Singapore time +08 (GMT+0800)
\ No newline at end of file
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-meta.py - thin wrappers around command modules
-
-Current responsibilities:
- 1. Load the incommon 'printenv' command module (no .py extension)
- using load_command_module.load_command_module().
- 2. Expose printenv() here, calling the imported printenv() work
- function with default arguments (equivalent to running without
- any CLI arguments).
- 3. Provide a simple version printer for this meta module.
- 4. Provide a small debug tag API (set/clear/has).
-"""
-
-from __future__ import annotations
-
-import datetime
-from load_command_module import load_command_module
-
-
-# Load the incommon printenv module once at import time
-_PRINTENV_MODULE = load_command_module("printenv")
-_Z_MODULE = load_command_module("Z")
-
-
-# Meta module version
-_major = 1
-_minor = 7
-def version_print() -> None:
- """
- Print the meta module version as MAJOR.MINOR.
- """
- print(f"{_major}.{_minor}")
-
-
-# Debug tag set and helpers
-_debug = set([
-])
-
-
-def debug_set(tag: str) -> None:
- """
- Add a debug tag to the meta debug set.
- """
- _debug.add(tag)
-
-
-def debug_clear(tag: str) -> None:
- """
- Remove a debug tag from the meta debug set, if present.
- """
- _debug.discard(tag)
-
-
-def debug_has(tag: str) -> bool:
- """
- Return True if the given debug tag is present.
- """
- return tag in _debug
-
-
-# Touch the default tag once so static checkers do not complain about
-# unused helpers when imported purely for side-effects.
-debug_has("Command")
-
-
-def printenv() -> int:
- """
- Call the imported printenv() work function with default arguments:
- - no null termination
- - no newline quoting
- - no specific names (print full environment)
- - prog name 'printenv'
- """
- return _PRINTENV_MODULE.printenv(
- False # null_terminate
- ,False # quote_newlines
- ,[] # names
- ,"printenv"
- )
-
-
-def z_format_mtime(
- mtime: float
-) -> str:
- """
- Format a POSIX mtime (seconds since epoch, UTC) using the Z module.
-
- Uses Z.ISO8601_FORMAT and Z.make_timestamp(dt=...).
- """
- dt = datetime.datetime.fromtimestamp(mtime, datetime.timezone.utc)
- return _Z_MODULE.make_timestamp(
- fmt=_Z_MODULE.ISO8601_FORMAT
- ,dt=dt
- )
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-newer - CLI test driver for skeleton.tree_dict_newer(A, B)
-
-Usage:
- newer <A_root> <B_root>
-"""
-
-from __future__ import annotations
-
-import os
-import sys
-from typing import Sequence
-
-import meta
-import skeleton
-
-
-def CLI(argv: Sequence[str] | None = None) -> int:
- if argv is None:
- argv = sys.argv[1:]
-
- prog = os.path.basename(sys.argv[0]) if sys.argv else "newer"
-
- if len(argv) != 2 or argv[0] in ("-h", "--help"):
- print(f"Usage: {prog} <A_root> <B_root>")
- return 1
-
- A_root = argv[0]
- B_root = argv[1]
-
- if not os.path.isdir(A_root):
- print(f"{prog}: {A_root}: not a directory")
- return 2
-
- if not os.path.isdir(B_root):
- print(f"{prog}: {B_root}: not a directory")
- return 3
-
- A = skeleton.tree_dict_make(A_root, None)
- B = skeleton.tree_dict_make(B_root, None)
-
- meta.debug_set("tree_dict_newer")
-
- _result = skeleton.tree_dict_newer(A, B)
-
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-older - CLI test driver for skeleton.tree_dict_older(A, B)
-
-Usage:
- older <A_root> <B_root>
-"""
-
-from __future__ import annotations
-
-import os
-import sys
-from typing import Sequence
-
-import meta
-import skeleton
-
-
-def CLI(argv: Sequence[str] | None = None) -> int:
- if argv is None:
- argv = sys.argv[1:]
-
- prog = os.path.basename(sys.argv[0]) if sys.argv else "older"
-
- if len(argv) != 2 or argv[0] in ("-h", "--help"):
- print(f"Usage: {prog} <A_root> <B_root>")
- return 1
-
- A_root = argv[0]
- B_root = argv[1]
-
- if not os.path.isdir(A_root):
- print(f"{prog}: {A_root}: not a directory")
- return 2
-
- if not os.path.isdir(B_root):
- print(f"{prog}: {B_root}: not a directory")
- return 3
-
- A = skeleton.tree_dict_make(A_root, None)
- B = skeleton.tree_dict_make(B_root, None)
-
- meta.debug_set("tree_dict_older")
-
- _result = skeleton.tree_dict_older(A, B)
-
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-skeleton.py - helpers for working with the Harmony skeleton tree
-"""
-
-from __future__ import annotations
-
-import os
-import sys
-from typing import Any, Callable, Dict, List, Set
-
-import meta
-from GitIgnore import GitIgnore
-import Harmony
-
-TreeDict = Dict[str, Dict[str, Any]]
-
-# tree_dict_make / tree_dict_print
-#
-# Build a dictionary describing a project tree, respecting GitIgnore.
-#
-# tree_dict_make(<path>, <checksum_fn>) -> tree_dict
-#
-# <checksum_fn>(<abs_path>) -> bignum | None
-#
-# Keys of tree_dict:
-# - Relative paths from <path>; the root itself is stored under "".
-#
-# Values are dicts with:
-# 1. 'mtime' : last modification time (float seconds) or None
-# 2. 'node_type' : 'file', 'directory', 'other', or 'constrained'
-# 3. 'dir_info' : 'NA', 'leaf', 'branch', 'root'
-# 4. 'checksum' : present only for file nodes when checksum_fn is
-# not None
-#
-# Traversal:
-# - Directories whose relative path GitIgnore.check() marks as
-# 'Ignore' are included in tree_dict but not traversed further.
-def tree_dict_make(
- path: str
- ,checksum_fn: Callable[[str], int] | None
-) -> Dict[str, Dict[str, Any]]:
- """
- Build a tree_dict for the subtree rooted at <path>, respecting GitIgnore.
-
- Semantics (current):
- * Any path (directory or file) for which GitIgnore.check(<rel_path>)
- returns 'Ignore' is completely omitted from the tree_dict.
- * The root directory ('') is always included.
- * Directory dir_info:
- - 'root' for the root
- - 'branch' for directories that have child directories
- (after GitIgnore filtering)
- - 'leaf' for directories with no child directories
- * Non-directory dir_info:
- - 'NA'
- * Symlinks are classified as file/directory/other based on what
- they point to, if accessible.
- * If any filesystem access needed for classification/mtime raises,
- the node is recorded as node_type='constrained', dir_info='NA',
- mtime=None, and we do not attempt checksum.
- """
- root = os.path.abspath(path)
- gi = GitIgnore(root)
-
- tree_dict: Dict[str, Dict[str, Any]] = {}
-
- for dirpath, dirnames, filenames in os.walk(root, topdown=True):
- rel_dir = os.path.relpath(dirpath, root)
- if rel_dir == ".":
- rel_dir = ""
-
- # Skip ignored directories (except the root).
- if rel_dir != "" and gi.check(rel_dir) == "Ignore":
- dirnames[:] = []
- continue
-
- # Filter child directories by GitIgnore so dir_info reflects
- # only directories we will actually traverse.
- kept_dirnames: List[str] = []
- for dn in list(dirnames):
- child_rel = dn if rel_dir == "" else os.path.join(rel_dir, dn)
- if gi.check(child_rel) == "Ignore":
- dirnames.remove(dn)
- else:
- kept_dirnames.append(dn)
-
- # Record the directory node itself
- dir_abs = dirpath
- try:
- dir_mtime = os.path.getmtime(dir_abs)
- dir_node_type = "directory"
- if rel_dir == "":
- dir_info = "root"
- elif kept_dirnames:
- dir_info = "branch"
- else:
- dir_info = "leaf"
- except OSError:
- # Could not stat the directory: treat as constrained.
- dir_mtime = None
- dir_node_type = "constrained"
- dir_info = "NA"
-
- tree_dict[rel_dir] = {
- "mtime": dir_mtime
- ,"node_type": dir_node_type
- ,"dir_info": dir_info
- }
-
- # For non-ignored directories, record files within
- for name in filenames:
- abs_path = os.path.join(dirpath, name)
- if rel_dir == "":
- rel_path = name
- else:
- rel_path = os.path.join(rel_dir, name)
-
- if gi.check(rel_path) == "Ignore":
- continue
-
- # Wrap classification + mtime in one try/except so any failure
- # marks the node as constrained.
- try:
- if os.path.islink(abs_path):
- # Symlink: classify by target if possible
- if os.path.isdir(abs_path):
- node_type = "directory"
- dir_info_f = "branch"
- elif os.path.isfile(abs_path):
- node_type = "file"
- dir_info_f = "NA"
- else:
- node_type = "other"
- dir_info_f = "NA"
- mtime = os.path.getmtime(abs_path)
- else:
- # Normal node
- if os.path.isfile(abs_path):
- node_type = "file"
- dir_info_f = "NA"
- elif os.path.isdir(abs_path):
- node_type = "directory"
- dir_info_f = "branch"
- else:
- node_type = "other"
- dir_info_f = "NA"
- mtime = os.path.getmtime(abs_path)
- except OSError:
- # Anything that blows up during classification/stat becomes
- # constrained; we do not attempt checksum for these.
- node_type = "constrained"
- dir_info_f = "NA"
- mtime = None
-
- info: Dict[str, Any] = {
- "mtime": mtime
- ,"node_type": node_type
- ,"dir_info": dir_info_f
- }
-
- if node_type == "file" and checksum_fn is not None and isinstance(mtime, (int, float)):
- info["checksum"] = checksum_fn(abs_path)
-
- tree_dict[rel_path] = info
-
- if meta.debug_has("tree_dict_print"):
- tree_dict_print(tree_dict)
-
- return tree_dict
-
-def tree_dict_print(
- tree_dict: Dict[str, Dict[str, Any]]
-) -> None:
- """
- Pretty-print a tree_dict produced by tree_dict_make() in fixed-width columns:
-
- [type] [dir] [mtime] [checksum?] [relative path]
-
- Only the values are printed in each column (no 'field=' prefixes).
- mtime is formatted via the Z module for human readability.
- """
- entries: List[tuple[str, str, str, str, str]] = []
- has_checksum = False
-
- for rel_path in sorted(tree_dict.keys()):
- info = tree_dict[rel_path]
- display_path = rel_path if rel_path != "" else "."
-
- type_val = str(info.get("node_type", ""))
- dir_val = str(info.get("dir_info", ""))
-
- raw_mtime = info.get("mtime")
- if isinstance(raw_mtime, (int, float)):
- mtime_val = meta.z_format_mtime(raw_mtime)
- else:
- mtime_val = str(raw_mtime)
-
- if "checksum" in info:
- checksum_val = str(info["checksum"])
- has_checksum = True
- else:
- checksum_val = ""
-
- entries.append((
- type_val
- ,dir_val
- ,mtime_val
- ,checksum_val
- ,display_path
- ))
-
- # Compute column widths
- type_w = 0
- dir_w = 0
- mtime_w = 0
- checksum_w = 0
-
- for type_val, dir_val, mtime_val, checksum_val, _ in entries:
- if len(type_val) > type_w:
- type_w = len(type_val)
- if len(dir_val) > dir_w:
- dir_w = len(dir_val)
- if len(mtime_val) > mtime_w:
- mtime_w = len(mtime_val)
- if has_checksum and len(checksum_val) > checksum_w:
- checksum_w = len(checksum_val)
-
- print("Tree dictionary contents:")
- for type_val, dir_val, mtime_val, checksum_val, display_path in entries:
- line = " "
- line += type_val.ljust(type_w)
- line += " "
- line += dir_val.ljust(dir_w)
- line += " "
- line += mtime_val.ljust(mtime_w)
-
- if has_checksum:
- line += " "
- line += checksum_val.ljust(checksum_w)
-
- line += " "
- line += display_path
-
- print(line)
-
-
-def tree_dict_A_minus_B(
- A: Dict[str, Dict[str, Any]]
- ,B: Dict[str, Dict[str, Any]]
-) -> Dict[str, Dict[str, Any]]:
- """
- Compute the set difference of two tree_dicts at the key level:
-
- Result = A \\ B
-
- That is, return a new tree_dict containing only those entries whose
- keys are present in A but NOT present in B.
- """
- result: Dict[str, Dict[str, Any]] = {}
-
- B_keys = set(B.keys())
-
- for key, info in A.items():
- if key not in B_keys:
- result[key] = info
-
- if meta.debug_has("tree_dict_A_minus_B"):
- tree_dict_print(result)
-
- return result
-
-
-def tree_dict_in_between_and_below(
- A: Dict[str, Dict[str, Any]]
- ,B: Dict[str, Dict[str, Any]]
-) -> tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]:
- """
- Partition nodes of B into two topology-based sets relative to A:
-
- 1. in_between:
- Nodes in B that lie under at least one directory node in A,
- but do NOT lie under any leaf directory of A.
-
- 2. below:
- Nodes in B that lie under at least one leaf directory of A.
-
- Definitions (relative to A's directory topology):
-
- - A directory node in A is any key whose info['node_type'] == 'directory'.
-
- - A leaf directory in A is a directory that has no *other* directory
- in A as a proper descendant. The project root ('') is therefore
- never a leaf (it always has descendant directories if the tree is
- non-trivial).
-
- - “Lies under”:
- * For a path p in B, we look at the chain of directory ancestors
- (including the root "") and, if p itself is a directory, p
- itself. Any of those that appear as directory keys in A are
- considered directory ancestors in A.
-
- * If any of those ancestors is a leaf in A, p goes to 'below'.
- Otherwise, if there is at least one directory ancestor in A,
- p goes to 'in_between'.
-
- - Nodes in B that do not lie under any directory in A are ignored.
-
- Returns:
- (in_between_dict, below_dict), both keyed like B and containing
- copies of the info dicts from B.
- """
- # 1. Collect all directory keys from A
- A_dir_keys: Set[str] = set(
- key for key, info in A.items()
- if info.get("node_type") == "directory"
- )
-
- # 2. Compute leaf directories in A
- leaf_dirs: Set[str] = set()
-
- for d in A_dir_keys:
- if d == "":
- continue
-
- has_child_dir = False
- prefix = d + os.sep
-
- for other in A_dir_keys:
- if other == d:
- continue
- if other.startswith(prefix):
- has_child_dir = True
- break
-
- if not has_child_dir:
- leaf_dirs.add(d)
-
- in_between: Dict[str, Dict[str, Any]] = {}
- below: Dict[str, Dict[str, Any]] = {}
-
- for key, info in B.items():
- # Skip B's root
- if key in ("", "."):
- continue
-
- parts = key.split(os.sep)
-
- # Build directory ancestor chain
- node_is_dir = (info.get("node_type") == "directory")
-
- ancestors: List[str] = [""]
- prefix = None
-
- if node_is_dir:
- upto = parts
- else:
- upto = parts[:-1]
-
- for part in upto:
- if prefix is None:
- prefix = part
- else:
- prefix = os.path.join(prefix, part)
- ancestors.append(prefix)
-
- # Filter ancestors to those that exist as directories in A
- ancestors_in_A = [d for d in ancestors if d in A_dir_keys]
-
- if not ancestors_in_A:
- # This B node is not under any directory from A; ignore it.
- continue
-
- # Any leaf ancestor in A?
- has_leaf_ancestor = any(d in leaf_dirs for d in ancestors_in_A)
-
- if has_leaf_ancestor:
- below[key] = info
- else:
- in_between[key] = info
-
- if meta.debug_has("tree_dict_in_between_and_below"):
- merged: Dict[str, Dict[str, Any]] = {}
- merged.update(in_between)
- merged.update(below)
- tree_dict_print(merged)
-
- return in_between, below
-
-
-def tree_dict_newer(
- A: Dict[str, Dict[str, Any]]
- ,B: Dict[str, Dict[str, Any]]
-) -> Dict[str, Dict[str, Any]]:
- """
- Return a dictionary of nodes from B that are newer than their
- corresponding nodes in A.
-
- For each key k:
-
- - If k exists in both A and B, and
- - B[k]['mtime'] > A[k]['mtime'],
-
- then k is included in the result with value B[k].
-
- Keys that are only in B (not in A) are ignored here.
- """
- result: Dict[str, Dict[str, Any]] = {}
-
- for key, info_B in B.items():
- info_A = A.get(key)
- if info_A is None:
- continue
-
- mtime_A = info_A.get("mtime")
- mtime_B = info_B.get("mtime")
-
- if mtime_A is None or mtime_B is None:
- continue
-
- if mtime_B > mtime_A:
- result[key] = info_B
-
- if meta.debug_has("tree_dict_newer"):
- tree_dict_print(result)
-
- return result
-
-
-def tree_dict_older(
- A: Dict[str, Dict[str, Dict[str, Any]]]
- ,B: Dict[str, Dict[str, Dict[str, Any]]]
-) -> Dict[str, Dict[str, Any]]:
- """
- Return a dictionary of nodes from B that are older than their
- corresponding nodes in A.
-
- For each key k:
-
- - If k exists in both A and B, and
- - B[k]['mtime'] < A[k]['mtime'],
-
- then k is included in the result with value B[k].
-
- Keys that are only in B (not in A) are ignored here.
- """
- result: Dict[str, Dict[str, Any]] = {}
-
- for key, info_B in B.items():
- info_A = A.get(key)
- if info_A is None:
- continue
-
- mtime_A = info_A.get("mtime")
- mtime_B = info_B.get("mtime")
-
- if mtime_A is None or mtime_B is None:
- continue
-
- if mtime_B < mtime_A:
- result[key] = info_B
-
- if meta.debug_has("tree_dict_older"):
- tree_dict_print(result)
-
- return result
-
-def in_between_newer(
- A: TreeDict
- ,B: TreeDict
-) -> TreeDict:
- """
- in_between_newer(A, B) -> TreeDict
-
- Return the subset of B's nodes that:
-
- 1. Are in the 'in_between' region with respect to A's topology:
- - under some directory that exists in A
- - NOT under any leaf directory in A
- (as defined by tree_dict_in_between_and_below), and
-
- 2. For file nodes:
- - are "newer" than A at the same path, or
- - are absent from A.
-
- More precisely:
- - If A has no entry for that path -> include.
- - If A has a non-file and B has a file -> include.
- - If both are files and B.mtime > A.mtime -> include.
-
- 3. For constrained nodes:
- - are always included, so that higher-level commands (e.g.
- 'import') can surface them as "not handled automatically".
-
- Notes:
- - Only file nodes participate in mtime comparisons.
- - Nodes with node_type == 'constrained' are passed through without
- mtime checks, so that callers can report them separately.
- """
- in_between, _below = tree_dict_in_between_and_below(A, B)
-
- result: TreeDict = {}
-
- # Keep track of directories already included in the result
- included_dirs: Set[str] = set()
-
- # Sort keys to ensure parent directories are processed before their children.
- # This is crucial for the child exclusion logic to work correctly.
- sorted_paths = sorted(in_between.keys(), key=len)
-
- for path in sorted_paths:
- b_info = in_between[path]
- b_type = b_info.get("node_type")
-
- # Constrained nodes: always surface so the caller can list them
- # under "not handled automatically".
- # Check if this path is a child of an already included directory
- is_child_of_included_dir = False
- for d in included_dirs:
- if path.startswith(d + os.sep):
- is_child_of_included_dir = True
- break
-
- if is_child_of_included_dir:
- continue
-
- # Constrained nodes: always surface so the caller can list them
- # under "not handled automatically".
- if b_type == "constrained":
- result[path] = b_info
- if b_type == "directory":
- included_dirs.add(path)
- continue
-
-
-
- b_mtime = b_info.get("mtime")
- a_info = A.get(path)
-
- # Case 1: path not in A at all -> include (new file/dir in in-between)
- if a_info is None:
- result[path] = b_info
- if b_type == "directory":
- included_dirs.add(path)
- continue
-
- # We only do "newer" semantics for regular files.
- if b_type != "file":
- continue
-
- a_type = a_info.get("node_type")
-
- # Case 2: A has non-file, B has file -> include
- if a_type != "file":
- result[path] = b_info
- # Note: b_type must be "file" here due to the check above, so no need
- # to check for directory inclusion.
- continue
-
- # Case 3: both are files; compare mtime
- a_mtime = a_info.get("mtime")
- if (
- isinstance(a_mtime, (int, float))
- and isinstance(b_mtime, (int, float))
- and b_mtime > a_mtime
- ):
- result[path] = b_info
- # Note: b_type must be "file" here, so no need to check for directory inclusion.
-
- if meta.debug_has("in_between_newer"):
- tree_dict_print(result)
-
- return result
-
--- /dev/null
+../authored/gitignore_treewalk.py
\ No newline at end of file
--- /dev/null
+2025-11-25 09:33:05 Z [subu:developer] Thomas_developer@StanleyPark
+§/home/Thomas/subu_data/developer/subu_data/Harmony§
+> find . -type l -exec ls -l {} \;
+lrwxrwxrwx 1 Thomas_developer Thomas_developer 35 Nov 25 09:08 ./tool/sync -> ../tool_shared/authored/sync/CLI.py
+lrwxrwxrwx 1 Thomas_developer Thomas_developer 3 May 19 2025 ./shared/third_party/Python/lib64 -> lib
+lrwxrwxrwx 1 Thomas_developer Thomas_developer 16 May 19 2025 ./shared/third_party/Python/bin/python3 -> /usr/bin/python3
+lrwxrwxrwx 1 Thomas_developer Thomas_developer 7 May 19 2025 ./shared/third_party/Python/bin/python -> python3
+lrwxrwxrwx 1 Thomas_developer Thomas_developer 7 May 19 2025 ./shared/third_party/Python/bin/python3.11 -> python3
+lrwxrwxrwx 1 Thomas_developer Thomas_developer 15 Nov 24 15:19 ./shared/authored/git-empty-dir/source_sync -> ../source_sync/
+lrwxrwxrwx 1 Thomas_developer Thomas_developer 25 Nov 24 15:21 ./shared/authored/git-empty-dir/Harmony.py -> ../source_sync/Harmony.py
+lrwxrwxrwx 1 Thomas_developer Thomas_developer 37 Nov 24 15:22 ./shared/authored/git-empty-dir/load_command_module.py -> ../source_sync/load_command_module.py
--- /dev/null
+../shared/authored/Harmony_sync/CLI.py
\ No newline at end of file
+++ /dev/null
-../tool_shared/authored/sync/CLI.py
\ No newline at end of file