# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
"""
-CLI.py - command classification and debug printer
+CLI.py - Harmony skeleton checker
Grammar (informal):
3. has_other_list
4. unclassified_list
-If the meta debug set contains the tag "Command", these four lists
+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 meta
+import os
import sys
from typing import Sequence
+
+import command
import doc
+import Harmony
+import meta
+import skeleton
meta.debug_set("print_command_lists")
-# print_command_lists tag sets (classification universe)
+# Command tag sets (classification universe)
HELP_COMMANDS: set[str] = set([
"version"
,"help"
Responsibilities:
1. Accept argv (or sys.argv[1:] by default).
- 2. Classify each argument using command_type().
+ 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.
- Behavior (current):
- 1. Build four lists, in argv order:
- - help_list
- - no_other_list
- - has_other_list
- - unclassified_list
- 2. If "print_command_lists" is enabled in meta's debug set, print those lists.
- 3. If 'environment' is present in no_other_list, call meta.printenv().
- 4. If any help commands appear, handle them and return 1.
+ 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] = []
- for arg in argv:
+ # 1. Classify head tokens
+ for arg in head:
ct = command_type(arg)
if ct == "Help":
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
,unclassified_list
)
+ # Help handling
if len(help_list) > 0:
if "version" in help_list:
meta.version_print()
doc.print_help()
return 1
-# status,Harmony_root = skeleton.where_is_Harmony()
-# if status == 'different':
-# print("Seems we are not running in the Harmony project, will exit.")
-# return 2
-# if status == 'not-found':
-# print("Harmony project not found, normally this command is run from with Harmony.")
-# return 3
-
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 -*-
+
+"""
+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:
+ * nodes in B that are newer than A (same relative path), and
+ * nodes in B that do not exist in A at all.
+ Direction: B → A
+
+ 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
+ (Uses the "older" list: B entries older than A → copy A→B.)
+
+ 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 meta
+import os
+from typing import Any, Dict, List, Tuple
+import skeleton
+
+
+TreeDict = Dict[str, Dict[str, Any]]
+
+def build_import_commands(
+ A_tree: Dict[str, Dict[str, Any]]
+ ,B_tree: Dict[str, Dict[str, Any]]
+ ,A_root: str
+ ,B_root: str
+) -> Tuple[List[str], List[str]]:
+ """
+ Compute shell commands to update A from B.
+
+ Returns:
+ (mkdir_cmds, cp_cmds)
+
+ Semantics:
+ - mkdir_cmds:
+ Directories that are directories in B, but are either missing
+ from A or not directories in A.
+ We *only* ever create dirs that are missing or wrong-type on A.
+
+ - cp_cmds:
+ Files (and optionally other non-directory nodes) 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).
+ """
+ mkdir_cmds: List[str] = []
+ cp_cmds: List[str] = []
+
+ for rel_path, b_info in B_tree.items():
+ node_type = b_info.get("node_type")
+
+ # Directories: candidate for mkdir on A if missing or wrong type.
+ if node_type == "directory":
+ a_info = A_tree.get(rel_path)
+ if a_info is None or a_info.get("node_type") != "directory":
+ # Missing or not a directory on A: mkdir -p
+ target_dir = os.path.join(A_root, rel_path) if rel_path else A_root
+ mkdir_cmds.append(f"mkdir -p '{target_dir}'")
+ continue
+
+ # Files / other nodes: candidate for cp from B -> A
+ b_mtime = b_info.get("mtime")
+ a_info = A_tree.get(rel_path)
+
+ need_copy = False
+
+ if a_info is None:
+ # B-only
+ need_copy = True
+ else:
+ a_type = a_info.get("node_type")
+ if a_type != "file":
+ # A has non-file, B has file/other: prefer B’s version
+ need_copy = True
+ else:
+ # Both are files: compare mtime
+ a_mtime = a_info.get("mtime")
+ if isinstance(a_mtime, (int, float)) and isinstance(b_mtime, (int, float)):
+ if b_mtime > a_mtime:
+ need_copy = True
+
+ if need_copy:
+ src = os.path.join(B_root, rel_path) if rel_path else B_root
+ dst = A_root # cp --parents will build the path under this root
+ cp_cmds.append(
+ f"cp --parents -a '{src}' '{dst}/'"
+ )
+
+ return mkdir_cmds, cp_cmds
+
+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: copy newer / A-missing nodes from B → A
+# ----------------------------------------------------------------------
+def _keys_only_in_B(
+ A: TreeDict
+ ,B: TreeDict
+) -> Iterable[str]:
+ keys_A = set(A.keys())
+ for k in B.keys():
+ if k not in keys_A:
+ yield k
+
+
+def cmd_import(
+ A_tree: Dict[str, Dict[str, Any]]
+ ,B_tree: Dict[str, Dict[str, Any]]
+ ,A_root: str
+ ,B_root: str
+) -> int:
+ """
+ import: show directory creation and copy commands B -> A.
+ """
+ mkdir_cmds, cp_cmds = build_import_commands(A_tree, B_tree, A_root, B_root)
+
+ print("== import: copy from B -> A ==")
+ print(f"# A root: {A_root}")
+ print(f"# B root: {B_root}")
+ print("#")
+
+ print("# Directories to create in A (mkdir -p):")
+ if mkdir_cmds:
+ for line in mkdir_cmds:
+ print(line)
+ else:
+ print("# (none)")
+ print("#")
+
+ print("# Files to copy from B -> A (cp --parents -a):")
+ if cp_cmds:
+ for line in cp_cmds:
+ print(line)
+ else:
+ print("# (none)")
+
+ return 0
+
+
+def cmd_import(
+ A: TreeDict
+ ,B: TreeDict
+ ,A_root: str
+ ,B_root: str
+) -> int:
+ """
+ import: B → A
+
+ - Newer nodes in B than A (same path): tree_dict_newer(A,B).
+ - Nodes present only in B (not in A).
+ - Only file nodes are turned into copy commands.
+
+ Output: shell 'cp' commands using GNU 'cp --parents -a'.
+ """
+ newer_B = skeleton.tree_dict_newer(A, B)
+ only_in_B_paths = list(_keys_only_in_B(A, B))
+
+ # Collect unique file paths to copy from B to A
+ paths: List[str] = []
+
+ for k in newer_B.keys():
+ if B.get(k, {}).get("node_type") == "file":
+ paths.append(k)
+
+ for k in only_in_B_paths:
+ if B.get(k, {}).get("node_type") == "file":
+ paths.append(k)
+
+ # Deduplicate while preserving order
+ seen = set()
+ unique_paths: List[str] = []
+ for p in paths:
+ if p in seen:
+ continue
+ seen.add(p)
+ unique_paths.append(p)
+
+ _print_header("import: copy from B → A")
+
+ if not unique_paths:
+ print("# No file nodes in B to import into A.")
+ return 0
+
+ print(f"# A root: {A_root}")
+ print(f"# B root: {B_root}")
+ print("# Copy newer and B-only files from B into A:")
+ for rel in unique_paths:
+ src = os.path.join(B_root, rel)
+ cmd = (
+ f"cp --parents -a {shell_quote(src)} "
+ f"{shell_quote(A_root)}/"
+ )
+ print(cmd)
+
+ return 0
+
+
+# ----------------------------------------------------------------------
+# export: copy newer / B-missing nodes from A → B
+# ----------------------------------------------------------------------
+def _keys_only_in_A(
+ A: TreeDict
+ ,B: TreeDict
+) -> Iterable[str]:
+ keys_B = set(B.keys())
+ for k in A.keys():
+ if k not in keys_B:
+ yield k
+
+
+def cmd_export(
+ A: TreeDict
+ ,B: TreeDict
+ ,A_root: str
+ ,B_root: str
+) -> int:
+ """
+ export: A → B
+
+ - Nodes in B that are older than A (same path):
+ tree_dict_older(A,B) -> keys of interest.
+ For these keys, we copy from A_root/path to B_root/path.
+
+ - Nodes present only in A (not in B).
+
+ Only file nodes are turned into copy commands.
+ """
+ older_B = skeleton.tree_dict_older(A, B)
+ only_in_A_paths = list(_keys_only_in_A(A, B))
+
+ paths: List[str] = []
+
+ for k in older_B.keys():
+ if A.get(k, {}).get("node_type") == "file":
+ paths.append(k)
+
+ for k in only_in_A_paths:
+ if A.get(k, {}).get("node_type") == "file":
+ paths.append(k)
+
+ seen = set()
+ unique_paths: List[str] = []
+ for p in paths:
+ if p in seen:
+ continue
+ seen.add(p)
+ unique_paths.append(p)
+
+ _print_header("export: copy from A → B")
+
+ if not unique_paths:
+ print("# No file nodes in A to export into B.")
+ return 0
+
+ print(f"# A root: {A_root}")
+ print(f"# B root: {B_root}")
+ print("# Copy newer and A-only files from A into B:")
+ for rel in unique_paths:
+ src = os.path.join(A_root, rel)
+ cmd = (
+ f"cp --parents -a {shell_quote(src)} "
+ f"{shell_quote(B_root)}/"
+ )
+ print(cmd)
+
+ 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.
+
+ Uses tree_dict_in_between_and_below(A,B) and prints the 'in_between'
+ dictionary.
+ """
+ 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.
+
+ These represent work added in proper extension points.
+ Uses the 'below' part from tree_dict_in_between_and_below(A,B).
+ """
+ _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).
+ """
+ # Normalize commands
+ cmds = set(has_other_list)
+
+ if "all" in cmds:
+ cmds.update([
+ "structure"
+ ,"import"
+ ,"export"
+ ,"suspicious"
+ ,"addendum"
+ ])
+
+ # Preserve a deterministic run order
+ 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:
+ # Unknown has_other token; ignore for now, could log later.
+ rc = 0
+
+ if rc != 0:
+ status = rc
+
+ return status
def _help_text(prog: str) -> str:
return f"""\
-{prog} — Harmony skeleton integrity and metadata checker
+{prog} - Harmony skeleton integrity and metadata checker
-For now:
- This is a placeholder help message.
+Syntax:
+ {prog} <command>* [<other>]
- The tool accepts one or more <command> tokens and an optional <other>
- argument. Each <command> is classified as one of:
+Where:
+ <other> :: path
+ <command> :: <help> | <no_other> | <has_other>
- - <help> (version, help, usage)
- - <no_other> (environment)
- - <has_other> (structure, import, export, suspicious, addendum, all)
+ <help> :: version | help | usage
+ <no_other> :: environment
+ <has_other> :: structure | import | export | suspicious | addendum | all
-Detailed behavior for each command will be documented here as the
-implementation is completed.
+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 from it. This is the 'default skeleton', or more 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
+ referred to as 'B'.
+
+ 4. If none of the commands require an <other> path argument, then it
+ should not be given. Otherwise it is required. A command that
+ requires an <other> path argument is called a <has_other> command.
+
+ 5. Implementation detail: all arguments except the last are first
+ treated as commands. If any of those are <has_other>, the last
+ argument is interpreted as the <other> path. If no <has_other>
+ appears before the last argument, the last argument is treated as
+ another command.
+
+Roots:
+ A = Skeleton project root (auto-detected). Currently this is the
+ Harmony skeleton, but {prog} is not limited to Harmony.
+
+ B = <other> project root (path argument when required).
+
+{prog} is used to ask questions about how <other> has changed relative
+to the current default skeleton. Changes may come from edits to the
+skeleton itself, edits to skeleton files in <other>, or files and
+directories added to <other>. Stated briefly, {prog} compares A with 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: a table of such directories.
+
+ import
+ - Suggest shell copy commands to update A from B:
+ * files in B that are newer than A at the same relative path
+ * files that exist in B but not in A
+ - Direction: B -> A
+ - Output: 'cp --parents -a' commands (to be reviewed/edited before use).
+
+ export
+ - Suggest shell copy commands to update B from A:
+ * files where the A copy is newer than B at the same path
+ * files that exist in A but not in B
+ - Direction: A -> B
+ - Output: 'cp --parents -a' commands (to be reviewed/edited before use).
+
+ suspicious
+ - Report nodes in B that lie "in between" the Harmony skeleton:
+ under a directory present in A, but not under any leaf directory
+ in A.
+ - Intended to highlight questionable placements that may indicate
+ misuse of the skeleton or candidates for new skeleton structure.
+
+ addendum
+ - Report nodes in B that lie "below" Harmony leaf directories:
+ work added in the intended extension points (tools, tests, etc.).
+ - Intended to show project-specific additions made in proper places.
+
+ all
+ - Run: structure, import, export, suspicious, addendum (in that order).
+
+Notes:
+ - Directory and file listings respect a simplified .gitignore model
+ plus some always-ignored patterns (such as '.git' directories).
+ - Timestamps are formatted via the Z helper in UTC (ISO 8601).
"""
-
def print_usage(
stream: TextIO | None = None
) -> None: