From: Thomas Walker Lynch Date: Wed, 19 Nov 2025 08:24:27 +0000 (+0000) Subject: . X-Git-Url: https://git.reasoningtechnology.com/?a=commitdiff_plain;h=784b4fd404a4330a2984948c469cad0c07ce5e4a;p=Harmony.git . --- diff --git a/tool/skel.tgz b/tool/skel.tgz new file mode 100644 index 0000000..7e2bf1a Binary files /dev/null and b/tool/skel.tgz differ diff --git a/tool/skeleton/CLI.py b/tool/skeleton/CLI.py index b62d3a8..46e810b 100755 --- a/tool/skeleton/CLI.py +++ b/tool/skeleton/CLI.py @@ -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 commands we compare: + + A = Harmony skeleton tree_dict + B = project tree_dict (path is the last argv token when any + 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 appear in a0..aN-2: + - aN-1 is treated as path (B_root), not classified. + + * If no 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 in head -> last arg is always path. + B_root = os.path.abspath(last) + else: + # 2B. No 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 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 index 0000000..47520b1 --- /dev/null +++ b/tool/skeleton/command.py @@ -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 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 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 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 diff --git a/tool/skeleton/doc.py b/tool/skeleton/doc.py index d822a02..a8d5351 100644 --- a/tool/skeleton/doc.py +++ b/tool/skeleton/doc.py @@ -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} * [] - The tool accepts one or more tokens and an optional - argument. Each is classified as one of: +Where: + :: path + :: | | - - (version, help, usage) - - (environment) - - (structure, import, export, suspicious, addendum, all) + :: version | help | usage + :: environment + :: structure | import | export | suspicious | addendum | all -Detailed behavior for each command will be documented here as the -implementation is completed. +Argument rules (informal): + 1. 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 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 path argument, then it + should not be given. Otherwise it is required. A command that + requires an path argument is called a command. + + 5. Implementation detail: all arguments except the last are first + treated as commands. If any of those are , the last + argument is interpreted as the path. If no + 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 = project root (path argument when required). + +{prog} is used to ask questions about how has changed relative +to the current default skeleton. Changes may come from edits to the +skeleton itself, edits to skeleton files in , or files and +directories added to . 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: