.
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Wed, 19 Nov 2025 08:24:27 +0000 (08:24 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Wed, 19 Nov 2025 08:24:27 +0000 (08:24 +0000)
tool/skel.tgz [new file with mode: 0644]
tool/skeleton/CLI.py
tool/skeleton/command.py [new file with mode: 0644]
tool/skeleton/doc.py

diff --git a/tool/skel.tgz b/tool/skel.tgz
new file mode 100644 (file)
index 0000000..7e2bf1a
Binary files /dev/null and b/tool/skel.tgz differ
index b62d3a8..46e810b 100755 (executable)
@@ -2,7 +2,7 @@
 # -*- 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):
 
@@ -25,23 +25,34 @@ At runtime, argv commands are classified into four lists:
   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"
@@ -105,29 +116,54 @@ def CLI(argv: Sequence[str] | None = None) -> int:
 
   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":
@@ -139,6 +175,37 @@ def CLI(argv: Sequence[str] | None = None) -> int:
     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
@@ -147,6 +214,7 @@ def CLI(argv: Sequence[str] | None = None) -> int:
       ,unclassified_list
     )
 
+  # Help handling
   if len(help_list) > 0:
     if "version" in help_list:
       meta.version_print()
@@ -156,21 +224,59 @@ def CLI(argv: Sequence[str] | None = None) -> int:
       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())
diff --git a/tool/skeleton/command.py b/tool/skeleton/command.py
new file mode 100644 (file)
index 0000000..47520b1
--- /dev/null
@@ -0,0 +1,460 @@
+#!/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
index d822a02..a8d5351 100644 (file)
@@ -52,23 +52,95 @@ Where:
 
 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: