From: Thomas Walker Lynch Date: Mon, 24 Nov 2025 15:49:49 +0000 (+0000) Subject: adding a git empty directory tool X-Git-Url: https://git.reasoningtechnology.com/style/static/git-logo.png?a=commitdiff_plain;h=ef92291e281f6571e317e5a5aae02c47ab47d7f2;p=Harmony.git adding a git empty directory tool --- diff --git a/developer/derived/.gitkeep b/developer/derived/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/developer/made/.gitkeep b/developer/made/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/developer/tool/promote b/developer/tool/promote new file mode 100755 index 0000000..e29cb43 --- /dev/null +++ b/developer/tool/promote @@ -0,0 +1,287 @@ +#!/usr/bin/env -S python3 -B +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +import os, sys, shutil, stat, pwd, grp, glob, tempfile + +HELP = """usage: release {write|clean|ls|help|dry write} [DIR] + write [DIR] Writes released files into $REPO_HOME/release. If [DIR] is specified, only writes files found in scratchpad/DIR. + clean [DIR] Remove the contents of the release directories. If [DIR] is specified, clean only the contents of that release directory. + ls List release/ as an indented tree: PERMS OWNER NAME (root-level dotfiles printed first). + help Show this message. + dry write [DIR] + Preview what write would do without modifying the filesystem. +""" + +ENV_MUST_BE = "developer/tool/env" +DEFAULT_DIR_MODE = 0o750 +PERM_BY_DIR = { + "kmod": 0o440, + "machine": 0o550, + "python3": 0o550, + "shell": 0o550, +} + +def exit_with_status(msg, code=1): + print(f"release: {msg}", file=sys.stderr) + sys.exit(code) + +def assert_env(): + env = os.environ.get("ENV", "") + if env != ENV_MUST_BE: + hint = ( + "ENV is not 'developer/tool/env'.\n" + "Enter the project with: source ./env_developer\n" + "That script exports: ROLE=developer; ENV=$ROLE/tool/env" + ) + exit_with_status(f"bad environment: ENV='{env}'. {hint}") + +def repo_home(): + rh = os.environ.get("REPO_HOME") + if not rh: + exit_with_status("REPO_HOME not set (did you 'source ./env_developer'?)") + return rh + +def dpath(*parts): + return os.path.join(repo_home(), "developer", *parts) + +def rpath(*parts): + return os.path.join(repo_home(), "release", *parts) + +def dev_root(): + return dpath() + +def rel_root(): + return rpath() + +def _display_src(p_abs: str) -> str: + # Developer paths shown relative to $REPO_HOME/developer + try: + if os.path.commonpath([dev_root()]) == os.path.commonpath([dev_root(), p_abs]): + return os.path.relpath(p_abs, dev_root()) + except Exception: + pass + return p_abs + +def _display_dst(p_abs: str) -> str: + # Release paths shown as literal '$REPO_HOME/release/' + try: + rel = os.path.relpath(p_abs, rel_root()) + rel = "" if rel == "." else rel + return "$REPO_HOME/release" + ("/" + rel if rel else "") + except Exception: + return p_abs + +def ensure_mode(path, mode): + try: os.chmod(path, mode) + except Exception: pass + +def ensure_dir(path, mode=DEFAULT_DIR_MODE, dry=False): + if dry: + if not os.path.isdir(path): + shown = _display_dst(path) if path.startswith(rel_root()) else ( + os.path.relpath(path, dev_root()) if path.startswith(dev_root()) else path + ) + print(f"(dry) mkdir -m {oct(mode)[2:]} '{shown}'") + return + os.makedirs(path, exist_ok=True) + ensure_mode(path, mode) + +def filemode(m): + try: return stat.filemode(m) + except Exception: return oct(m & 0o777) + +def owner_group(st): + try: return f"{pwd.getpwuid(st.st_uid).pw_name}:{grp.getgrgid(st.st_gid).gr_name}" + except Exception: return f"{st.st_uid}:{st.st_gid}" + +# ---------- LS with two-pass column width for owner:group ---------- +def list_tree(root): + if not os.path.isdir(root): + return + + # gather entries in display order, record owner:group widths + entries = [] # list of (is_dir, depth, perms, ownergrp, name) + def gather(path: str, depth: int, is_root: bool): + try: + it = list(os.scandir(path)) + except FileNotFoundError: + return + dirs = [e for e in it if e.is_dir(follow_symlinks=False)] + files = [e for e in it if not e.is_dir(follow_symlinks=False)] + dirs.sort(key=lambda e: e.name) + files.sort(key=lambda e: e.name) + + if is_root: + # root-level: dotfiles first + for f in (e for e in files if e.name.startswith(".")): + st = os.lstat(f.path) + entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name)) + for d in dirs: + st = os.lstat(d.path) + entries.append((True, depth, filemode(st.st_mode), owner_group(st), d.name + "/")) + gather(d.path, depth + 1, False) + for f in (e for e in files if not e.name.startswith(".")): + st = os.lstat(f.path) + entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name)) + else: + # subdirs: dirs then files (dotfiles naturally sort first) + for d in dirs: + st = os.lstat(d.path) + entries.append((True, depth, filemode(st.st_mode), owner_group(st), d.name + "/")) + gather(d.path, depth + 1, False) + for f in files: + st = os.lstat(f.path) + entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name)) + + gather(root, depth=1, is_root=True) + + # compute max width for owner:group column + ogw = 0 + for (_isdir, _depth, _perms, ownergrp, _name) in entries: + if len(ownergrp) > ogw: + ogw = len(ownergrp) + + # print + print("release/") + for (isdir, depth, perms, ownergrp, name) in entries: + indent = " " * depth + # perms first, owner:group padded next, then name with tree indent + print(f"{perms} {ownergrp:<{ogw}} {indent}{name}") + +# ---------- end LS ---------- + +def iter_src_files(topdir, src_root): + base = os.path.join(src_root, topdir) if topdir else src_root + if not os.path.isdir(base): + return + yield + if topdir == "kmod": + for p in sorted(glob.glob(os.path.join(base, "*.ko"))): + yield (p, os.path.basename(p)) + else: + for root, dirs, files in os.walk(base): + dirs.sort(); files.sort() + for fn in files: + src = os.path.join(root, fn) + rel = os.path.relpath(src, base) + yield (src, rel) + +def target_mode(topdir): + return PERM_BY_DIR.get(topdir, 0o440) + +def copy_one(src_abs, dst_abs, mode, dry=False): + src_show = _display_src(src_abs) + dst_show = _display_dst(dst_abs) + parent = os.path.dirname(dst_abs) + os.makedirs(parent, exist_ok=True) + + if dry: + if os.path.exists(dst_abs): + print(f"(dry) unlink '{dst_show}'") + print(f"(dry) install -m {oct(mode)[2:]} -D '{src_show}' '{dst_show}'") + return + + # Replace even if dst exists and is read-only: write temp then atomic replace. + fd, tmp_path = tempfile.mkstemp(prefix=".tmp.", dir=parent) + try: + with os.fdopen(fd, "wb") as tmpf, open(src_abs, "rb") as sf: + shutil.copyfileobj(sf, tmpf) + tmpf.flush() + os.chmod(tmp_path, mode) + os.replace(tmp_path, dst_abs) + finally: + try: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception: + pass + + print(f"+ install -m {oct(mode)[2:]} '{src_show}' '{dst_show}'") + +def write_one_dir(topdir, dry): + rel_root_dir = rpath() + src_root = dpath("scratchpad") + src_dir = os.path.join(src_root, topdir) + dst_dir = os.path.join(rel_root_dir, topdir) + + if not os.path.isdir(src_dir): + exit_with_status( + f"cannot write: expected '{_display_src(src_dir)}' to exist. " + f"Create scratchpad/{topdir} (Makefiles may need to populate it)." + ) + + ensure_dir(dst_dir, DEFAULT_DIR_MODE, dry=dry) + + wrote = False + mode = target_mode(topdir) + for src_abs, rel in iter_src_files(topdir, src_root): + dst_abs = os.path.join(dst_dir, rel) + copy_one(src_abs, dst_abs, mode, dry=dry) + wrote = True + if not wrote: + msg = "no matching artifacts found" + if topdir == "kmod": msg += " (looking for *.ko)" + print(f"(info) {msg} in {_display_src(src_dir)}") + +def cmd_write(dir_arg, dry=False): + assert_env() + ensure_dir(rpath(), DEFAULT_DIR_MODE, dry=dry) + + src_root = dpath("scratchpad") + if not os.path.isdir(src_root): + exit_with_status(f"cannot find developer scratchpad at '{_display_src(src_root)}'") + + if dir_arg: + write_one_dir(dir_arg, dry=dry) + else: + subs = sorted([e.name for e in os.scandir(src_root) if e.is_dir(follow_symlinks=False)]) + if not subs: + print(f"(info) nothing to release; no subdirectories found under {_display_src(src_root)}") + return + for td in subs: + write_one_dir(td, dry=dry) + +def _clean_contents(dir_path): + if not os.path.isdir(dir_path): return + for name in os.listdir(dir_path): + p = os.path.join(dir_path, name) + if os.path.isdir(p) and not os.path.islink(p): + shutil.rmtree(p, ignore_errors=True) + else: + try: os.unlink(p) + except FileNotFoundError: pass + +def cmd_clean(dir_arg): + assert_env() + rel_root_dir = rpath() + if not os.path.isdir(rel_root_dir): + return + if dir_arg: + _clean_contents(os.path.join(rel_root_dir, dir_arg)) + else: + for e in os.scandir(rel_root_dir): + if e.is_dir(follow_symlinks=False): + _clean_contents(e.path) + +def CLI(): + if len(sys.argv) < 2: + print(HELP); return + cmd, *args = sys.argv[1:] + if cmd == "write": + cmd_write(args[0] if args else None, dry=False) + elif cmd == "clean": + cmd_clean(args[0] if args else None) + elif cmd == "ls": + list_tree(rpath()) + elif cmd == "help": + print(HELP) + elif cmd == "dry": + if args and args[0] == "write": + cmd_write(args[1] if len(args) >= 2 else None, dry=True) + else: + print(HELP) + else: + print(HELP) + +if __name__ == "__main__": + CLI() diff --git a/developer/tool/release b/developer/tool/release deleted file mode 100755 index e29cb43..0000000 --- a/developer/tool/release +++ /dev/null @@ -1,287 +0,0 @@ -#!/usr/bin/env -S python3 -B -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- - -import os, sys, shutil, stat, pwd, grp, glob, tempfile - -HELP = """usage: release {write|clean|ls|help|dry write} [DIR] - write [DIR] Writes released files into $REPO_HOME/release. If [DIR] is specified, only writes files found in scratchpad/DIR. - clean [DIR] Remove the contents of the release directories. If [DIR] is specified, clean only the contents of that release directory. - ls List release/ as an indented tree: PERMS OWNER NAME (root-level dotfiles printed first). - help Show this message. - dry write [DIR] - Preview what write would do without modifying the filesystem. -""" - -ENV_MUST_BE = "developer/tool/env" -DEFAULT_DIR_MODE = 0o750 -PERM_BY_DIR = { - "kmod": 0o440, - "machine": 0o550, - "python3": 0o550, - "shell": 0o550, -} - -def exit_with_status(msg, code=1): - print(f"release: {msg}", file=sys.stderr) - sys.exit(code) - -def assert_env(): - env = os.environ.get("ENV", "") - if env != ENV_MUST_BE: - hint = ( - "ENV is not 'developer/tool/env'.\n" - "Enter the project with: source ./env_developer\n" - "That script exports: ROLE=developer; ENV=$ROLE/tool/env" - ) - exit_with_status(f"bad environment: ENV='{env}'. {hint}") - -def repo_home(): - rh = os.environ.get("REPO_HOME") - if not rh: - exit_with_status("REPO_HOME not set (did you 'source ./env_developer'?)") - return rh - -def dpath(*parts): - return os.path.join(repo_home(), "developer", *parts) - -def rpath(*parts): - return os.path.join(repo_home(), "release", *parts) - -def dev_root(): - return dpath() - -def rel_root(): - return rpath() - -def _display_src(p_abs: str) -> str: - # Developer paths shown relative to $REPO_HOME/developer - try: - if os.path.commonpath([dev_root()]) == os.path.commonpath([dev_root(), p_abs]): - return os.path.relpath(p_abs, dev_root()) - except Exception: - pass - return p_abs - -def _display_dst(p_abs: str) -> str: - # Release paths shown as literal '$REPO_HOME/release/' - try: - rel = os.path.relpath(p_abs, rel_root()) - rel = "" if rel == "." else rel - return "$REPO_HOME/release" + ("/" + rel if rel else "") - except Exception: - return p_abs - -def ensure_mode(path, mode): - try: os.chmod(path, mode) - except Exception: pass - -def ensure_dir(path, mode=DEFAULT_DIR_MODE, dry=False): - if dry: - if not os.path.isdir(path): - shown = _display_dst(path) if path.startswith(rel_root()) else ( - os.path.relpath(path, dev_root()) if path.startswith(dev_root()) else path - ) - print(f"(dry) mkdir -m {oct(mode)[2:]} '{shown}'") - return - os.makedirs(path, exist_ok=True) - ensure_mode(path, mode) - -def filemode(m): - try: return stat.filemode(m) - except Exception: return oct(m & 0o777) - -def owner_group(st): - try: return f"{pwd.getpwuid(st.st_uid).pw_name}:{grp.getgrgid(st.st_gid).gr_name}" - except Exception: return f"{st.st_uid}:{st.st_gid}" - -# ---------- LS with two-pass column width for owner:group ---------- -def list_tree(root): - if not os.path.isdir(root): - return - - # gather entries in display order, record owner:group widths - entries = [] # list of (is_dir, depth, perms, ownergrp, name) - def gather(path: str, depth: int, is_root: bool): - try: - it = list(os.scandir(path)) - except FileNotFoundError: - return - dirs = [e for e in it if e.is_dir(follow_symlinks=False)] - files = [e for e in it if not e.is_dir(follow_symlinks=False)] - dirs.sort(key=lambda e: e.name) - files.sort(key=lambda e: e.name) - - if is_root: - # root-level: dotfiles first - for f in (e for e in files if e.name.startswith(".")): - st = os.lstat(f.path) - entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name)) - for d in dirs: - st = os.lstat(d.path) - entries.append((True, depth, filemode(st.st_mode), owner_group(st), d.name + "/")) - gather(d.path, depth + 1, False) - for f in (e for e in files if not e.name.startswith(".")): - st = os.lstat(f.path) - entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name)) - else: - # subdirs: dirs then files (dotfiles naturally sort first) - for d in dirs: - st = os.lstat(d.path) - entries.append((True, depth, filemode(st.st_mode), owner_group(st), d.name + "/")) - gather(d.path, depth + 1, False) - for f in files: - st = os.lstat(f.path) - entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name)) - - gather(root, depth=1, is_root=True) - - # compute max width for owner:group column - ogw = 0 - for (_isdir, _depth, _perms, ownergrp, _name) in entries: - if len(ownergrp) > ogw: - ogw = len(ownergrp) - - # print - print("release/") - for (isdir, depth, perms, ownergrp, name) in entries: - indent = " " * depth - # perms first, owner:group padded next, then name with tree indent - print(f"{perms} {ownergrp:<{ogw}} {indent}{name}") - -# ---------- end LS ---------- - -def iter_src_files(topdir, src_root): - base = os.path.join(src_root, topdir) if topdir else src_root - if not os.path.isdir(base): - return - yield - if topdir == "kmod": - for p in sorted(glob.glob(os.path.join(base, "*.ko"))): - yield (p, os.path.basename(p)) - else: - for root, dirs, files in os.walk(base): - dirs.sort(); files.sort() - for fn in files: - src = os.path.join(root, fn) - rel = os.path.relpath(src, base) - yield (src, rel) - -def target_mode(topdir): - return PERM_BY_DIR.get(topdir, 0o440) - -def copy_one(src_abs, dst_abs, mode, dry=False): - src_show = _display_src(src_abs) - dst_show = _display_dst(dst_abs) - parent = os.path.dirname(dst_abs) - os.makedirs(parent, exist_ok=True) - - if dry: - if os.path.exists(dst_abs): - print(f"(dry) unlink '{dst_show}'") - print(f"(dry) install -m {oct(mode)[2:]} -D '{src_show}' '{dst_show}'") - return - - # Replace even if dst exists and is read-only: write temp then atomic replace. - fd, tmp_path = tempfile.mkstemp(prefix=".tmp.", dir=parent) - try: - with os.fdopen(fd, "wb") as tmpf, open(src_abs, "rb") as sf: - shutil.copyfileobj(sf, tmpf) - tmpf.flush() - os.chmod(tmp_path, mode) - os.replace(tmp_path, dst_abs) - finally: - try: - if os.path.exists(tmp_path): - os.unlink(tmp_path) - except Exception: - pass - - print(f"+ install -m {oct(mode)[2:]} '{src_show}' '{dst_show}'") - -def write_one_dir(topdir, dry): - rel_root_dir = rpath() - src_root = dpath("scratchpad") - src_dir = os.path.join(src_root, topdir) - dst_dir = os.path.join(rel_root_dir, topdir) - - if not os.path.isdir(src_dir): - exit_with_status( - f"cannot write: expected '{_display_src(src_dir)}' to exist. " - f"Create scratchpad/{topdir} (Makefiles may need to populate it)." - ) - - ensure_dir(dst_dir, DEFAULT_DIR_MODE, dry=dry) - - wrote = False - mode = target_mode(topdir) - for src_abs, rel in iter_src_files(topdir, src_root): - dst_abs = os.path.join(dst_dir, rel) - copy_one(src_abs, dst_abs, mode, dry=dry) - wrote = True - if not wrote: - msg = "no matching artifacts found" - if topdir == "kmod": msg += " (looking for *.ko)" - print(f"(info) {msg} in {_display_src(src_dir)}") - -def cmd_write(dir_arg, dry=False): - assert_env() - ensure_dir(rpath(), DEFAULT_DIR_MODE, dry=dry) - - src_root = dpath("scratchpad") - if not os.path.isdir(src_root): - exit_with_status(f"cannot find developer scratchpad at '{_display_src(src_root)}'") - - if dir_arg: - write_one_dir(dir_arg, dry=dry) - else: - subs = sorted([e.name for e in os.scandir(src_root) if e.is_dir(follow_symlinks=False)]) - if not subs: - print(f"(info) nothing to release; no subdirectories found under {_display_src(src_root)}") - return - for td in subs: - write_one_dir(td, dry=dry) - -def _clean_contents(dir_path): - if not os.path.isdir(dir_path): return - for name in os.listdir(dir_path): - p = os.path.join(dir_path, name) - if os.path.isdir(p) and not os.path.islink(p): - shutil.rmtree(p, ignore_errors=True) - else: - try: os.unlink(p) - except FileNotFoundError: pass - -def cmd_clean(dir_arg): - assert_env() - rel_root_dir = rpath() - if not os.path.isdir(rel_root_dir): - return - if dir_arg: - _clean_contents(os.path.join(rel_root_dir, dir_arg)) - else: - for e in os.scandir(rel_root_dir): - if e.is_dir(follow_symlinks=False): - _clean_contents(e.path) - -def CLI(): - if len(sys.argv) < 2: - print(HELP); return - cmd, *args = sys.argv[1:] - if cmd == "write": - cmd_write(args[0] if args else None, dry=False) - elif cmd == "clean": - cmd_clean(args[0] if args else None) - elif cmd == "ls": - list_tree(rpath()) - elif cmd == "help": - print(HELP) - elif cmd == "dry": - if args and args[0] == "write": - cmd_write(args[1] if len(args) >= 2 else None, dry=True) - else: - print(HELP) - else: - print(HELP) - -if __name__ == "__main__": - CLI() diff --git a/document/Harmony/00_Project_Structure.org b/document/Harmony/00_Project_Structure.org index d55148c..8c08098 100644 --- a/document/Harmony/00_Project_Structure.org +++ b/document/Harmony/00_Project_Structure.org @@ -1,50 +1,162 @@ -#+TITLE: 00 - Project Structure and Prescriptive Ontology +#+TITLE: 00 - Project Structure and Ontology #+AUTHOR: Harmony Project Team #+DATE: 2025-11-21 #+OPTIONS: toc:2 num:nil +#+HTML_HEAD_EXTRA: +#+HTML_HEAD_EXTRA: + +#+BEGIN_EXPORT html +
+#+END_EXPORT + * Purpose -The Harmony skeleton is cloned as a starting point for git based project development. Harmony was designed to serve as a central working point for a team. -* 1. Some nomenclature. +Harmony provides a language agnostic project directory structure and maintenance tools for long-lived, multi-person team software development. The structure exists to enforce: + +1. Clarity about where things live. +1.1. Role based work areas +1.2. Separation of skeleton, team member authored, machine-made, and third party installed software. +3. A safe, predictable build and release workflow. + +A newcomer should be able to clone Harmony and understand the entire +working model in minutes. + +To make a new project a toolsmith first clones Harmony, renames it to the project name, resets the history, and disconnects it from the Harmony project. The skeleton of the new project can be kept in sync with the Harmony skeleton by going to a Harmony skeleton clone and running the =Harmony/tool/sync= tool. + +Harmony is IDE agnostic. I typically use Emacs as an IDE and encourage its use. Because of this the documents standard format is emacs `.org` format. Files in this format can be exported to other formats, such as HTML. I have also used IntelliJ IDEA and Eclipse with Harmony, though the project skeleton has drifted some since then. I would like to update Harmony to work out of the box with these and other IDEs in the future. + +* 1. Key Concepts + +** Created vs. Made +Harmony divides the world into two categories: + +- *Created / Authored* + Human-written: source files, docs, design notes. + +- *Made* + Tool-produced: binaries, generated sources, intermediates. + +This separation protects authored material from accidental overwrite and +makes build artifacts fully disposable. + +** Semantic Paths +Directory names in Harmony are not decorative. +Each directory name is a *property* shared among files. Thus, a full path forms a semantic +sentence describing said files. + +Example: + +- =developer/authored/= + “Developer authored code” + +- =developer/scratchpad/made/= + “Developer → scratch workspace → tool-made binaries” + +Once you learn the ontology, you can infer the meaning of any path. + +* Top-Level Repository Layout + +The layout below is stable across all Harmony skeleton based projects: + +| Directory | Meaning | +|----------|---------| +| =developer/= | Primary workspace for developers | +| =tester/= | Regression and validation workspace for testers | +| =tool/= | Project-local tools | +| =tool_shared/= | Shared ecosystem tools | +| =document/= | Documentation (local to project) | +| =release/= | Central Working Point for promoted artifacts | +| =scratchpad/= | Global scratch (misc experiments) | +| =env_* = | Role activators | + +The `env_*` files prepare PATH, set environment variables, and cd into +the correct workspace. A team member will source one of the =env_*= files to take on a role in the project. As of this writing the supported roles are: toolsmith, developer, and tester. + +* The =release/= tree. + +The =release/= tree is where developers put work product that is to be shared with testers. Once the contents of the =release/= directory are blessed by the tester, the project will be given a release branch, and then the =release/= tree contains the files that are shared with users. Users should not be pulling files from anywhere else in the project tree. + +The =release/= tree is owned by the developer. No other role should write into this tree. + +Ideally, artifacts arrive in the =release/= tree *only* then the developer invokes the =promote= tool. And take note, The developer's =promote= script, as initially provided with the Harmony skeleton, has a command for erasing the contents of the release directory. + +** =release/made_tracked/= + Architecture-agnostic artifacts. Tracked by Git, comes when the project is cloned. Users update the release directory when on a release branch, by pulling the project. + +** =release/made_untracked/= + Architecture-specific artifacts. Directory tracked, contents are git ignored. The contents of this directory are created by running a build after the project is cloned/pulled. This was a compromise to avoid the problem of maintaining architecture and platform specific binaries. + +** =release/documnt/= + Documents for users of the code in the release directory. + +* The =developer/= tree + +This property is set, i.e. this director is entered, by first going to the top level directory of the project, then sourcing the =env_developer= environment file. The developer can hook additional items into the environment by putting them into the =developer/tool/env= file. + +** =authored/= + Human-written source. Tools should never delete files in this directory. =authored/= files are tracked. + +** =made/= + + Generated by tools, and the artifacts are tracked. These artifacts + are stable across machine architectures. A common item to find in + the =developer/made/= directory is a link to a Python program in + the =authored/= directory. When following RT conventions the entry + point of command line driven Python files is `CLI.py`, so the link + in =developer/made/= gives the program a name. + +** =experiment/= + Try-it-here code. Short-lived. Developers do spot testing here. If tests are to longer lived, they should be moved to the tester role. + +** =scratchpad/= + Contents of this directory are git ignored. It is intended to hold all intermediate build outputs, and anything else the developer might consider scratchpad work. + +** =scratchpad/made/= + By RT convention, architecture specific build artifacts are not tracked, but rather are built each time the project is cloned. Such build artifacts are placed in =developer/scratchpad/made= and if they are to be shared with the tester, the release script will release them to =release/made_untracted=. + +** =tool/= + Developer specific tools. Additional tools will be found under =tool_shared=. If the project is not self contained, then yet additional tools might come from the system environment. + +* Documents + +** =release/document/= + Documentation for users of released code. E.g.s a =man= page, user manual for an application, or a reference manually for a released library. + +** =document/= + Project wide documentation for project team members. + +** =developer/document/= + Documentation for developers. + +** =tester/document/= + Documentation for testers. + +** =tool_shared/document/= + Documentation on installing the shared tools. Note if a tool has a document directory that remains with the tool. + This will typically have a list of tools that need to be installed for the project, and notes to help make installs go more smoothly. + +* Tools -** The Foundational Principle: Create vs. Make -- Something **Created** came from God, Artists, or, apparently SQL interpreters. We will include developers in with Artists.;-) Created written material is **Authored**. -- Something **Made** is the result of assembling materials into a product. This is done by **Factories** and **Build Tools**. +** =tool/= -** Property Schema (The Semantic Path) -Directory names are properties bestowed upon their contents. The path is a descriptive sentence: -- =developer/authored/main.c= -> "The =main.c= file is **authored** by the developer." +We call the team members who administer the project and install tools the 'toolsmith'. The top level =tool/= directory holds the toolsmith's tools. -* 2. Directory Semantics +** =tool_shared/= -** =developer/authored/= -- **Invariant:** Primary Logic Source. -- **Contents:** All human-written source code (C, Python, etc.). +Shared tools are available to all team members. Those that have been written specifically for the Harmony skeleton or for this project go into the =tool_shared/bespoke= directory. Note the tool =scratchpad=, try =scratchpat help=. -** =developer/made/= -- **Invariant:** Architecture-specific Output. +Tools installed from third parties go into the git ignored directory =tool_shared/third_party=. -- **Contents:** Intended to hold compiled programs and links back into authored interpreted programs. As a general rule, if the contents of a "made" - directory are deleted, the contents can be remade from files already in the project, though perhaps with the use of third party tools and libraries. +** =developer/tool= -** =developer/scratchpad/= -- **Invariant:** Transient Workspace. -- **Contents:** Intermediate files, object files, dependency files. +Developer role specific tools. The =release= script and the RT project shared =make= scripts are found here. -** =developer/scratchpad/made/= -- **Invariant:** Machine-Made Binaries (Intermediate), generally OS loadable made things that are not to be tracked. -- **Contents:** Compiled binaries and libraries derived by the build system. The contents of =scratchpad/= are permanently Git-ignored. +** =tester/tool= -* 3. Release Structure (The Central Working Point) -The =$REPO_HOME/release= directory holds candidates for testing and promotion. +Tester role specific tools. -** =release/made_tracked/= -- **Invariant:** Shared, Agnostic Output. -- **Contents:** Architecture-agnostic final release candidates (TRACKED). -** =release/made_untracked/= -- **Invariant:** Local Action Required / Machine-Specific Output. -- **Rule:** This directory is empty but **TRACKED** (by a committed =.gitignore=) to signal that a **local build must be performed** post-clone to populate it with executables. -- **Contents:** Architecture-specific binaries (IGNORED). - +#+BEGIN_EXPORT html +
+#+END_EXPORT diff --git a/document/Harmony/01_Workflow_and_Build_Contract.org b/document/Harmony/01_Workflow_and_Build_Contract.org index 2fc8f23..6826ca6 100644 --- a/document/Harmony/01_Workflow_and_Build_Contract.org +++ b/document/Harmony/01_Workflow_and_Build_Contract.org @@ -1,33 +1,146 @@ #+TITLE: 01 - Workflow and Build Contract #+AUTHOR: RT +#+DATE: 2025-11-21 #+OPTIONS: toc:2 num:nil -* 1. Core Build Principles (The Contract) -This contract enforces read/write separation and predictable build orchestration. +#+HTML_HEAD_EXTRA: +#+HTML_HEAD_EXTRA: -** 1.1 The Read-Only Contract -- **Authored Trees** (=developer/authored/=) are read-only to all scripts. -- **Write Area (SCRATCHPAD):** All synthesis, intermediates, binaries, and kbuild outputs must be directed to **=developer/scratchpad/=**. +#+BEGIN_EXPORT html +
+#+END_EXPORT -** 1.2 Orchestration and Discovery -- **Invocation:** Builds are invoked from the =$REPO_HOME/developer= directory. -- **Discovery:** Artifacts are discovered automatically by standardized file suffix (e.g., =*.lib.c=, =*.cli.c=, =*.mod.c=). Hard-coding file basenames is prohibited. +* Purpose +The workflow contract defines the steps from authorship through release of work product. -* 2. Project Roles and Workflow -The workflow minimizes friction by defining responsibilities and establishing a clear testing cycle. +There are three circular loops. -** 2.1 Roles -- **Developer:** Translates specs into code, evolving the =authored= tree. -- **Tester:** Runs regression suite and validates candidates. -- **Toolsmith:** Maintains the skeleton and shared tools. +In the development loop, developers author code and run experiments, eventually then promoting work product to the =release/= directory. -** 2.2 Release Promotion (The Explicit Step) -Promotion is an **explicit copy step** managed by the Python release script, not an automatic part of the build process. Build outputs remain in =developer/scratchpad/= until promotion. +In the developer tester loop, testers test the promoted release candidates and file issues against them, developers address these, and then promote new release candidates. Or in a tighter version of this loop, a developer with a local copy of this project plays both roles so as to speed the cycle. -** 2.3 New Release Targets -The release script must promote artifacts to these canonical locations: +In the third loop, the tester finds the release candidates to meet the goals for the release version, and to be of sufficient quality that they create a new release branch. Released code then has bug reports filed against it. Developers address these and the prior two loops are run until a new release candidate is stable, and a new release branch is made. -| Content Type | Developer Origin | Release Target | Invariant | -| :--- | :--- | :--- | :--- | -| **Binaries/Kmod** | scratchpad/made | **release/made_untracked/** | Architecture-Specific (IGNORED) | -| **Agnostic Scripts** | developer/made | **release/made_tracked/** | Shared, TRACKED | +Release branches have two integer numbers. The first number is the version of the software, as per the architectural specification. (That specification is placed into the project document directory.) The second number counts the number of times the tester has created a release branch for said version of the software. + +The workflow is designed for forward motion of through release numbers, so as to avoid having to maintain older releases separately. It is better to give a customer a new release if a bug must be fixed, even the customer will not pay for the new release, that it is to pay the cost of dealing with multi-release level bug fixes. However, as each release has its own branch, it is possible to perform multi-release level bug fixes if that is what is required or desired. + +* Roles and Process + +** Developer Role +Responsibilities: + +1. Write and modify authored source. Ensure code meets RT style (see =02_RT_Code_Format.org=). +2. Run builds. +3. Spot testing in =experiment/= +4. Promotes release candidates for more thorough testing using the customized =prommote= script. +5. Rinse, lather, repeat. + +** Tester Role +Responsibilities: + +1. Validate candidates under =release/=. +2. Run regression suites. +3. Approve for quality and completeness, and create release branches. + +** Toolsmith Role +Responsibilities: + +1. Setup the project directory, and keep the project in sync with the Harmony skeleton. +2. Maintain the role environments, apart from the =/tool/env= files which are owned by the respective ==. +3. Install and maintained shared tools, =tool/= and =tool_shared/=, and other tools upon request. +4. Address issues with project workflow. Propose updates to the Harmony skeleton. + + +* Entering the project + +What I do to enter a project is to first run an emacs shell. I cd to the project I want to work on, and then source the =env_toolsmith=, =env_developer=, or =env_tester= file, depending on which role I want to work in. Although sourcing these files affects the environment of the shell I am running, it does not effect the environment of emacs. Hence after sourcing the environment, I launch an IDE. This newly launched IDE will have a correct environment. For myself, these days, that new IDE will be emacs again. + +It is common that I will have two or three IDE's (emacs invocations) running side by side, each as different roles. Then I can write code, spot test it, promote it, then change to the other IDE and run regression tests. And if it is a phase of the project where tools are in flux, I will use the third IDE for modifying tools. Hence, as one person I will taken on three roles simultaneously, each in a different IDE. + +On a large project, chances are that all team members will be doing something similar to this on their local clones of the project. However, there will be team members concentrating on code development, and others on testing and release. Early on a toolsmith will setup the project, and then continue to maintain it. + +* Developer + +** Authoring and Building + +Developers write the build fodder files in the =authored/= directory. File name extensions are used to signal to the build tools how the build fodder is to be used. When the conventional single extension giving the main file type is not enough, two extensions are used. + +For example, with the default makefile for C, compiler fodder is found in the =authored/= directory, each file has one these file name extensions: + +- CLIs end in =.cli.c= +- Libary code source end in =.lib.c= +- Kernel module sources are =.mod.c= + +Fodder with the =.cli.c= extension is made into a stand alone executable. + +Fodder with =.lib.c= extension is compiled as an object file and added to the =lib.a= archive. The =.cli.c= files are linkedin against said archive. + +Build tools never write into the =developer/authored= directory. Build products that are not to be tracked go on the =scratchpad/=. Those that are tracked go into the =developer/made= directory. + +It is expected that developers customize and add to the build scripts that come with the Harmony skeleton in order to fit their specific build needs. Note the Ariadne project for complex builds. + +** Developer Testing + +Spot tests are run in the =experiment/= directory. If the tests grow complex or are to be kept for the long term, move them to the tester environment. + +Once the developer finds the edits to be stable he or she can promote them. The promoted code is referred to as release candidates. Promoted release candidates can then be read by the tester role. + +As I mentioned, it is not uncommon for a team member to have two IDEs open, with one being in the developer environment, and one being in the tester environment, and then to bounce back and fourth between them. + +Once the release candidate code is stable, the developer can pull the remote repo, address merge conflicts, then push the local repo back. Merge conflicts on tracked release candidates are common as it is a bottleneck point in the code development. + +** Promotion for release + +As mentioned, files are promoted from the developer environment to the top level =release/= directory by the developer. The developer effects promotion for release by running the customized =developer/tool/promote= script, and then pushing the repository. Only a tester can actually perform a release. + +Building and promotion are separate activities. + +- No tool may rebuild during promotion. +- Promotion is a copy-only operation. +- No builds are run in the =release/= directory. + +If architecture specific files are to be part of the release, the developer will develop a =build_untracked= script and promote it into the =release/tool= directory. Then when a user clones a released project, as a second step the user will invoke the =release/tool/build_untracked= script. That script will fill in the =release/made_untracked= directory with code built specifically for the user's platform. + +- =release/documnt/= (documents for those who intend to use the work product) +- =release/authored= (interpreter fodder - _none are run directly_) +- =release/made_tracked/= (pushed to remote, pulled from remote, links into authored scripts) +- =release/made_untracked/= (local-only) +- =release/tool/= (=build_untracked= and other tools for maintaining released code) + +We chose the 'build after clone' approach over the 'thousand architecture specific binary release directories' approach, because maintaining many architecture release files became a maintenance problem. Note this new approach requires that third party tools be installed so that the =release/tool/build_untracked= script can run. This is the trade off cost for nothing having the thousand architecture directories. + +A user of the Harmony skeleton is free to customize the promotion tool and go back to multiple architecture specific binary release directories if that is what they want. + +Clearly if work product is intended to be distributed to lay users, there must be a deployment step after the release step, but we do not address this in these documents, as it this is not part of Harmony. + + +* Tester + +The developer has promoted release candidates to the =release/= directory. He or she claims those represent a complete high quality product at the given release level. The testers are going to prove the developers to be wrong about that claim. If testers can't disprove this claim, the testers will make a release branch at the next minor release number for the given major release version. + +- The tester reads the spec, and writes a complete set of feature tests. + +- The tester uses the Mosaic test tool, and writes a set of tests, first for the individual functions that make up the program, then for functions in groups. + +- The tester accumulates tests for each bug that ever comes back on a release. + +- The tester collects tests from the developer when they are offered. + +- The tester writes other tests as he or she sees fit. + +- When the tests pass, one presumes, the tester will create a release branch. + +* Separation of roles. + +A tester never patches code in the =developer/= directory, instead the tester files issues. A tester could propose a code fix on another branch, and then point the developers at it in the issue report. + +A developer never writes into =tester/=, instead a developer adds to the =experiment/= and offers to share tests. A developer can propose tests on another branch, and then point testers at it. + +It is up the project manager how strict role assignments will be. + +As mentioned before, one person can play multiple roles. For example, it makes perfect sense for a developer with a local copy of the repo, to have an IDE open as a tester, so that he or she can run tests on release candidates before pushing them. However, in when doing this, the test code might be read only. The developer is merely running it and has no plans to push changes to it. + +#+BEGIN_EXPORT html +
+#+END_EXPORT diff --git a/document/Harmony/02_RT_Code_Format.org b/document/Harmony/02_RT_Code_Format.org index 7d6f554..0e9298e 100644 --- a/document/Harmony/02_RT_Code_Format.org +++ b/document/Harmony/02_RT_Code_Format.org @@ -3,59 +3,155 @@ #+DATE: 2025-11-21 #+OPTIONS: toc:2 num:nil +#+HTML_HEAD_EXTRA: +#+HTML_HEAD_EXTRA: + +#+BEGIN_EXPORT html +
+#+END_EXPORT + * Purpose -The RT Code Format is **Prescriptive**; it defines the mandatory aesthetic and structural rules that override language defaults. The rules aim for a dense output and universal consistency. + +The goal is consistency, readability, and predictability across all +languages and tools. + +This document covers: + +1. Naming conventions +2. Vertical comma lists +3. Enclosure spacing +4. Line breaks and indentation +5. Cross-language guidance * 1. Naming Conventions ** 1.1 Identifier Naming -- **Namespaces/Types:** Use *PascalCase*. -- **Functions/Variables:** Use *snake_case*. -** 1.2 Variable Suffixes (File System and Interface) -Use suffixes to specify the interface or type for clarity: -- **File System:** =_fp= (file path), =_afp= (absolute file path), =_fn= (file name). -- **Interfaces:** =_list= (ordered collection), =_set= (set of items), =_f= (function). +- Types, modules: *PascalCase* +- Functions, variables: *snake_case* +- Globals: UPPER_SNAKE_CASE + +** 1.2 Suffix Semantics +Optionally suffixes are added to variable names to suggest type or interface. + +- =*_dp :: directory path, not specified if relative or absolute +- =*_dpr :: relative directory path +- =*_dpa :: absolute directory path + +- =*_fp :: file path, not specified if relative or absolute +- =*_fpr :: relative file path +- =*_fpa :: absolute file path + +- =*_list= :: generic ordered items +- =*_seq= :: ordered items accessed by index + +- =*_map= :: a keyed container +- =*_dict :: a keyed container + +- =*_count= :: number of elements +- =*_flag= :: boolean -* 2. Operator and Assignment -- **Multiplication/Division:** **No space** around `*` or `/`. -- **Other Binary Operators:** **One space** around all others (e.g., `+`, `-`). -- **Sampling Assignment:** **No space** around the assignment operator (`=`) when used inside a conditional (e.g., `if(result=condition())`). -- **Single Statement Assignment:** **One space** around the assignment operator (`=`). +- = *_Type :: names specific type, where the type name is capitalized. E.g.s =name_Array= or =name_Map= for the cases that name is an instance of a defined Array or Map type. -* 3. Commas (The Syntactic Append Operator) -This is the most distinctive rule: -- **Spacing:** **One space *before*** the comma, and **no space *after*** the comma (e.g., `item_a ,item_b`). -- **Line Breaks:** Break lines **before** the comma, not after. +Add a container type suffix instead of making variables names plural. For example, + +- =name_seq= :: a sequence of zero or more names, used in place of =names=. + + +* Comma separated list + +RT code format treats the comma in a list as belong to the item that caused the comma to be needed. Hence, this is an example of a horizontal comma separated list: + +#+BEGIN_SRC c + int x ,y ,z; +#+END_SRC + +Note the space before the comma, and the comma abuts the item that caused the comma to be needed. It does not matter if the last occurs in a language statement or in a data value. + +For a vertical comma list, the comma remains attached to the item that caused the comma to be needed: #+BEGIN_SRC c result = some_function( - first_argument - ,second_argument_with_longer_name + first_argument + ,second_argument + ,third_argument ); #+END_SRC -* 4. Enclosure Spacing (Parentheses, Braces, Brackets) +Example in Python: + +#+BEGIN_SRC python +items = [ + first_item + ,second_item + ,third_item +] +#+END_SRC + +- Two-space indent. +- Comma at column after indentation. +- All items aligned except the first, as it does not have a comma before it. +- Works identically across C, Python, Bash arrays, and JSON-like data. + +* 3. Enclosure Spacing + +This rule applies on a line by line basis. -- **Native Rule:** There is **no native space** before or after enclosure punctuation. -- **Adjacency:** When two enclosures appear side-by-side (like `)(` or `}{`), there is **no space** between them. -- **Nesting Exception:** For single-line enclosures that contain **at least one nested enclosure**, insert **one space** after the opening punctuation and **one space** before the closing punctuation to aid readability. +** For one level enclosures: + +- No space before =)=, =]=, =}=. +- No space before or after enclosure pairs. +- One-space exception: short single-line enclosures for readability. + +Conforming: -* 5. Short Stuff Rule -For simple `if`, `for`, and `while` statements that introduce a single-line clause: if the condition and clause together are simple, they should **remain on a single line without braces**. #+BEGIN_SRC c -if( x > 0 ) return x; -while( has_next() ) process_next(); +if(condition){ + do_something(); +} #+END_SRC + +Non conforming: -* 6. C-Specific Conventions +#+BEGIN_SRC c +if(condition) { + do_something(); +} +#+END_SRC -** 6.1 Integrated Headers and Namespaces -- **Integrated Header:** C projects adopt the practice of integrating the interface (header) directly into the source file, toggled by the **`FACE`** preprocessor macro, eliminating separate `.h` files. -- **Ad Hoc Namespace:** Exported C identifiers must be prefixed with a module-specific name followed by the **`·` (cdot) character** (e.g., `void Server·run();`) to avoid naming collisions. +Non conforming: -** 6.2 File Extensions -- **Library:** Files implementing library functions use the **`.lib.c`** extension. -- **CLI:** Files implementing command-line tools use the **`.cli.c`** extension. +#+BEGIN_SRC c +if ( condition ) { + do_something ( ); +} +#+END_SRC - +** For multi-level enclosures: + +One space around the level one (outermost) enclosure punctuation. Other levels are as for the one level enclosure. + +#+BEGIN_SRC c +if( f(g(x)) ){ + do_something(); +} +#+END_SRC + +In this example the =if= has a three level enclosure structure. (When measuring enclosure levels identifiers are ignored.) Hence level one gets one space of padding, while all other levels get no padding. + +** Unmatched enclosure punctuation. + +Format the enclosure punctuation that is present, as though it were matched. + + +* Indentation + +Harmony mandates: + +- Two spaces per indentation level. +- Never use tabs. +- Nest lines under the syntactic element that opened them. + +#+BEGIN_EXPORT html +
+#+END_EXPORT diff --git a/document/Harmony/03_Naming_and_Directory_Conventions.org b/document/Harmony/03_Naming_and_Directory_Conventions.org new file mode 100644 index 0000000..d719d5b --- /dev/null +++ b/document/Harmony/03_Naming_and_Directory_Conventions.org @@ -0,0 +1,33 @@ +#+TITLE: 03 - Naming and Directory Conventions +#+AUTHOR: RT +#+DATE: 2025-11-21 +#+OPTIONS: toc:2 num:nil +#+HTML_HEAD_EXTRA: +#+HTML_HEAD_EXTRA: + +#+BEGIN_EXPORT html +
+#+END_EXPORT + +A directory name is taken a property for a set of files. Consequently, directory names are rarely plural. E.g. suppose we have a number of test files in a directory. The directory would be named =test=. As each file in the directory has the property of being a test. + +It would be nice if we could attach multiple properties to a file as part of the file system framework, but conventional file systems do not support this. Consequently, when needed, people add a second property to a file use dot extensions to the file's name. Hence, we get something like =sqrt.c= in a directory called =source=. So the first property is that the file is source code, and the second property is that it is C code. + +We could extent the dot suffix model of adding a property to file by using multiple dot suffixes. Our C makefile structure makes use of this. + +So what is a reasonable primary property for a set of files? Perhaps: + +- Who uses each file with this property. Home directories are named like this. +- The role of the people using the file. This is a more generic version of the prior rule. The =developer= and =tester= directories were named in this manner. +- What program are the files for. Thus we might name a directory a bunch of files for the cc compiler `cc`. +- The generic category of program said files are for. Thus we end up with directories called =src= or =executable=. + +As for the names =src= and =executable= those come from times when almost all programs were compiled. We prefer instead the names =authored= and =made=. =authored= files are those written by humans (or these days, perhaps AI), while =made= files are products of tools. For a Python program, we put packages in =authored= with a module called =CLI.py= for the command line interface. Then we link from =made= into =authored= so as to give the program a name. + +The RT C coding environment does not use separate source and header files. Instead a variable is set that gates off the implementation if the source code is to be used as a header. Hence, all of our C source fits fine within and =authored= directory. + + + +#+BEGIN_EXPORT html +
+#+END_EXPORT diff --git a/document/Harmony/04_Language_Addenda.org b/document/Harmony/04_Language_Addenda.org new file mode 100644 index 0000000..b9b4c77 --- /dev/null +++ b/document/Harmony/04_Language_Addenda.org @@ -0,0 +1,170 @@ +#+TITLE: 04 - Language Addenda (C, Python, Bash) +#+AUTHOR: RT +#+DATE: 2025-11-21 +#+OPTIONS: toc:2 num:nil +#+HTML_HEAD_EXTRA: +#+HTML_HEAD_EXTRA: + +#+BEGIN_EXPORT html +
+#+END_EXPORT + + +* Purpose +The RT code format is language-agnostic, but actual languages differ in +syntax and constraints. + +This document explains how the RT rules are applied in: + +1. C +2. Python +3. Bash + +For each language we answer: + +1. What carries over directly from =02_RT_Code_Format.org=. +2. What must be adapted. +3. What extra discipline is required. + +* 1. C Addendum + +** 1.1 Control Structure and File Layout + +The detailed RT C file structure is described in the dedicated = +RT_C_control_structure= document. The core ideas: + +1. Each module has an *Interface* section and an *Implementation* + section in the same file. +2. The sections are toggled using preprocessor macros (e.g. =FACE=). +3. Interface declarations are processed even when included multiple + times; the implementation is compiled only when used as an + implementation. + +This approach: + +1. Keeps the interface and implementation in sync. +2. Avoids maintaining parallel =.h= and =.c= files for each module. +3. Integrates smoothly with standardized makefiles. + +** 1.2 Indentation and Comma Lists + +C code follows the RT two-space indentation and vertical comma lists: + +#+BEGIN_SRC c +result = some_function( + first_argument + ,second_argument_with_longer_name + ,third_argument +); +#+END_SRC + +Rules: + +1. Two spaces per block indentation. +2. The comma starts the line in vertical lists. +3. Align continuation lines under the first symbol after the equals + sign or opening parenthesis when feasible. + +** 1.3 Error Handling and Ownership + +Guidelines: + +1. Functions should document ownership of pointers and lifetimes. +2. Prefer explicit =*_count= parameters over sentinel values when + passing arrays. +3. Return codes should be consistent (=0= success, non-zero failure) or + use clearly documented enums. + +* 2. Python Addendum + +** 2.1 Indentation and Layout + +Python enforces indentation syntactically, so the RT two-space rule +becomes: + +1. Use *two-space indentation* for all Python code, even though four is + common in the wider ecosystem. +2. Vertical comma lists still place the comma at the start of the line, + after the indentation. + +Example: + +#+BEGIN_SRC python +items = [ + first_item + ,second_item + ,third_item +] +#+END_SRC + +** 2.2 Modules and CLI Separation + +Python scripts distinguish between: + +1. *Work functions* (importable API). +2. *CLI entry points* (argument parsing, printing, exit codes). + +Pattern: + +1. Put reusable logic into functions and classes. +2. Put argument parsing and =if __name__ == "__main__":= in the CLI + section. +3. Keep side effects out of import time. + +** 2.3 Error Handling + +1. Raise exceptions for exceptional conditions. +2. Catch exceptions at the CLI boundary and convert them into user + messages and exit codes. +3. Avoid catching broad =Exception= unless it is immediately converted + into a controlled failure. + +* 3. Bash Addendum + +** 3.1 Shebang and Safety + +Bash scripts should start with: + +#+BEGIN_SRC sh +#!/usr/bin/env bash +set -euo pipefail +#+END_SRC + +Explanation: + +1. =-e= :: Exit on error. +2. =-u= :: Treat unset variables as errors. +3. =-o pipefail= :: Propagate errors across pipelines. + +** 3.2 Functions vs. Top-Level Code + +RT-style Bash separates: + +1. A small top-level CLI harness (argument parsing, usage, dispatch). +2. A set of functions that implement the work. + +Pattern: + +1. Parse arguments into variables. +2. Call a main function with explicit parameters. +3. Avoid relying on global mutable state where possible. + +** 3.3 Logging and Diagnostics + +1. Use =printf= or =echo= for user-facing messages. +2. Send debug or trace output to stderr (=>&2=). +3. Make it obvious when the script is changing system state (e.g. + mounting, creating users, modifying firewall rules). + +* 4. Using the Addenda + +When in doubt: + +1. Start with =02_RT_Code_Format.org= for the core rules. +2. Apply the relevant language section here. +3. If a language requires deviation from the generic rules, document + that deviation in this file instead of ad-hoc decisions. + +#+BEGIN_EXPORT html +
+#+END_EXPORT diff --git a/document/Harmony/style/rt_dark_doc.css b/document/Harmony/style/rt_dark_doc.css new file mode 100644 index 0000000..bac4fbf --- /dev/null +++ b/document/Harmony/style/rt_dark_doc.css @@ -0,0 +1,44 @@ + + body { + font-family: 'Noto Sans JP', Arial, sans-serif; + background-color: hsl(0, 0%, 0%); + color: hsl(42, 100%, 80%); + padding: 2rem; + } + .page { + padding: 3rem; + margin: 1.25rem auto; + max-width: 46.875rem; + background-color: hsl(0, 0%, 0%); + box-shadow: 0 0 0.625rem hsl(42, 100%, 50%); + } + h1 { + font-size: 1.5rem; + text-align: center; + color: hsl(42, 100%, 84%); + text-transform: uppercase; + margin-top: 1.5rem; + } + h2 { + font-size: 1.25rem; + color: hsl(42, 100%, 84%); + text-align: center; + margin-top: 2rem; + } + h3 { + font-size: 1.125rem; + color: hsl(42, 100%, 75%); + margin-top: 1.5rem; + } + p, li { + color: hsl(42, 100%, 90%); + text-align: justify; + margin-bottom: 1rem; + } + code { + font-family: 'Courier New', Courier, monospace; + background-color: hsl(0, 0%, 25%); + padding: 0.125rem 0.25rem; + color: hsl(42, 100%, 90%); + } + diff --git a/release/tool/.gitkeep b/release/tool/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tool/git-empty-dir b/tool/git-empty-dir new file mode 120000 index 0000000..51e7f2a --- /dev/null +++ b/tool/git-empty-dir @@ -0,0 +1 @@ +source_git-empty-dir/CLI.py \ No newline at end of file diff --git a/tool/git-tar b/tool/git-tar new file mode 100755 index 0000000..79d5a8c --- /dev/null +++ b/tool/git-tar @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +""" +git-tar — Create an archive of the current Git repo's ref into ./scratchpad + +Commands (order-insensitive): + git-tar # default: tar.gz (HEAD, ./scratchpad, Z-stamp if importable) + git-tar help # show help + git-tar version # show version + git-tar ref- # choose ref (tag/branch/commit), default HEAD + git-tar out- # choose output directory (default: /scratchpad) + git-tar no-stamp # force omit timestamp even if Z is importable + git-tar z-format- # override timestamp format used with Z (optional) + git-tar zip # write .zip instead of .tar.gz + git-tar tar # force .tar.gz explicitly + +Output names: + __[__].tar.gz + __[__].zip +""" + +from __future__ import annotations +import gzip, os, pathlib, subprocess, sys +from typing import Optional +import importlib, importlib.util +from importlib.machinery import SourceFileLoader + +VERSION = "1.5" + +# ---------------------------------------------------------------------- +# Editable timestamp format (used when calling Z) +# ---------------------------------------------------------------------- +Z_FORMAT = "%year-%month-%day_%hour%minute%secondZ" + +USAGE = f"""git-tar {VERSION} + +Usage: + git-tar [commands...] + +Commands (order-insensitive): + help + version + ref- + out- + no-stamp + z-format- + zip + tar + +Examples: + git-tar + git-tar zip + git-tar ref-main out-/tmp + git-tar z-format-%year-%month-%dayT%hour:%minute:%second.%scintillaZ +""".rstrip() + +# ---------------------------------------------------------------------- +# git helpers +# ---------------------------------------------------------------------- +def _run(*args: str, check: bool = True, cwd: Optional[pathlib.Path] = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args + ,check=check + ,cwd=(str(cwd) if cwd else None) + ,text=True + ,stdout=subprocess.PIPE + ,stderr=subprocess.PIPE + ) + +def _in_git_repo() -> bool: + try: + return _run("git","rev-parse","--is-inside-work-tree").stdout.strip().lower() == "true" + except subprocess.CalledProcessError: + return False + +def _git_top() -> pathlib.Path: + return pathlib.Path(_run("git","rev-parse","--show-toplevel").stdout.strip()) + +def _git_ref_label(repo_top: pathlib.Path, ref: str) -> str: + try: + return _run("git","-C",str(repo_top),"describe","--tags","--always","--dirty",ref).stdout.strip() + except subprocess.CalledProcessError: + return _run("git","-C",str(repo_top),"rev-parse","--short",ref).stdout.strip() + +# ---------------------------------------------------------------------- +# Z module discovery (supports extension-less file named "Z") +# ---------------------------------------------------------------------- +def _import_Z_module(repo_top: pathlib.Path) -> Optional[object]: + try: + return importlib.import_module("Z") + except Exception: + pass + + candidates: list[pathlib.Path] = [] + here = pathlib.Path(__file__).resolve().parent + candidates += [here / "Z", here / "Z.py"] + candidates += [ + repo_top / "tool_shared" / "third_party" / "RT-project-share" / "release" / "python" / "Z", + repo_top / "tool_shared" / "third_party" / "RT-project-share" / "release" / "python" / "Z.py", + repo_top / "tool_shared" / "third_party" / "RT-project-share" / "release" / "bash" / "Z", + ] + for d in (pathlib.Path(p) for p in (os.getenv("PATH") or "").split(os.pathsep) if p): + p = d / "Z" + if p.exists() and p.is_file(): + candidates.append(p) + + for path in candidates: + try: + if not path.exists() or not path.is_file(): + continue + spec = importlib.util.spec_from_loader("Z", SourceFileLoader("Z", str(path))) + if not spec or not spec.loader: + continue + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[attr-defined] + if hasattr(mod,"make_timestamp") or (hasattr(mod,"get_utc_dict") and hasattr(mod,"format_timestamp")): + return mod + except Exception: + continue + return None + +# ---------------------------------------------------------------------- +# Z stamp helper (format string visible & editable above) +# ---------------------------------------------------------------------- +def make_z_stamp(zmod: object, z_format: str) -> Optional[str]: + try: + if hasattr(zmod, "make_timestamp"): + s = zmod.make_timestamp(fmt=z_format) # type: ignore[attr-defined] + return (str(s).strip().replace("\n","") or None) + if hasattr(zmod, "get_utc_dict") and hasattr(zmod, "format_timestamp"): + td = zmod.get_utc_dict() # type: ignore[attr-defined] + s = zmod.format_timestamp(td, z_format) # type: ignore[attr-defined] + return (str(s).strip().replace("\n","") or None) + except Exception: + return None + return None + +# ---------------------------------------------------------------------- +# archiving +# ---------------------------------------------------------------------- +def _stream_git_archive_tar(repo_top: pathlib.Path, prefix: str, ref: str, out_gz_path: pathlib.Path) -> None: + proc = subprocess.Popen( + ["git","-C",str(repo_top),"archive","--format=tar",f"--prefix={prefix}/",ref] + ,stdout=subprocess.PIPE + ) + try: + with gzip.open(out_gz_path,"wb") as gz: + while True: + chunk = proc.stdout.read(1024 * 1024) # 1 MiB + if not chunk: + break + gz.write(chunk) + finally: + if proc.stdout: + proc.stdout.close() + rc = proc.wait() + if rc != 0: + try: + out_gz_path.unlink(missing_ok=True) + finally: + raise subprocess.CalledProcessError(rc, proc.args) + +def _stream_git_archive_zip(repo_top: pathlib.Path, prefix: str, ref: str, out_zip_path: pathlib.Path) -> None: + # Directly stream git's zip to file; no Python zip building needed. + proc = subprocess.Popen( + ["git","-C",str(repo_top),"archive","--format=zip",f"--prefix={prefix}/",ref] + ,stdout=subprocess.PIPE + ) + try: + with open(out_zip_path, "wb") as f: + while True: + chunk = proc.stdout.read(1024 * 1024) + if not chunk: + break + f.write(chunk) + finally: + if proc.stdout: + proc.stdout.close() + rc = proc.wait() + if rc != 0: + try: + out_zip_path.unlink(missing_ok=True) + finally: + raise subprocess.CalledProcessError(rc, proc.args) + +# ---------------------------------------------------------------------- +# work function +# ---------------------------------------------------------------------- +def work( + ref: str = "HEAD" + ,outdir: Optional[pathlib.Path] = None + ,force_no_stamp: bool = False + ,z_format: Optional[str] = None + ,archive_kind: str = "tar" # "tar" or "zip" +) -> pathlib.Path: + if archive_kind not in ("tar","zip"): + raise RuntimeError("archive_kind must be 'tar' or 'zip'") + + if not _in_git_repo(): + raise RuntimeError("not inside a git repository") + + repo_top = _git_top() + repo_name = repo_top.name + ref_label = _git_ref_label(repo_top, ref) + + stamp: Optional[str] = None + if not force_no_stamp: + zmod = _import_Z_module(repo_top) + if zmod is not None: + stamp = make_z_stamp(zmod, z_format or Z_FORMAT) + + target_dir = (outdir or (repo_top / "scratchpad")) + target_dir.mkdir(parents=True, exist_ok=True) + + suffix = ".zip" if archive_kind == "zip" else ".tar.gz" + out_name = f"{repo_name}__{ref_label}{('__' + stamp) if stamp else ''}{suffix}" + out_path = target_dir / out_name + + if archive_kind == "zip": + _stream_git_archive_zip(repo_top, repo_name, ref, out_path) + else: + _stream_git_archive_tar(repo_top, repo_name, ref, out_path) + + return out_path + +# ---------------------------------------------------------------------- +# CLI with command tokens +# ---------------------------------------------------------------------- +def CLI(argv: Optional[list[str]] = None) -> int: + if argv is None: + argv = sys.argv[1:] + + # defaults + ref = "HEAD" + outdir: Optional[pathlib.Path] = None + force_no_stamp = False + z_format: Optional[str] = None + archive_kind = "tar" + + # no args → do the default action + if not argv: + try: + print(f"Wrote {work(ref=ref, outdir=outdir, force_no_stamp=force_no_stamp, z_format=z_format, archive_kind=archive_kind)}") + return 0 + except Exception as e: + print(f"git-tar: {e}", file=sys.stderr); return 1 + + # consume tokens (order-insensitive) + for arg in argv: + if arg in ("help","-h","--help"): + print(USAGE); return 0 + if arg == "version": + print(f"git-tar {VERSION}"); return 0 + if arg == "no-stamp": + force_no_stamp = True; continue + if arg == "zip": + archive_kind = "zip"; continue + if arg == "tar": + archive_kind = "tar"; continue + if arg.startswith("ref-"): + ref = arg[4:] or ref; continue + if arg.startswith("out-"): + od = arg[4:]; outdir = pathlib.Path(od).resolve() if od else None; continue + if arg.startswith("z-format-"): + z_format = arg[len("z-format-"):] or None; continue + print(f"git-tar: unknown command '{arg}'", file=sys.stderr); return 1 + + # run + try: + out_path = work(ref=ref, outdir=outdir, force_no_stamp=force_no_stamp, z_format=z_format, archive_kind=archive_kind) + except Exception as e: + print(f"git-tar: {e}", file=sys.stderr); return 1 + + print(f"Wrote {out_path}") + return 0 + +# ---------------------------------------------------------------------- +if __name__ == "__main__": + raise SystemExit(CLI()) diff --git a/tool/skeleton_compare b/tool/skeleton_compare deleted file mode 120000 index 968c6c2..0000000 --- a/tool/skeleton_compare +++ /dev/null @@ -1 +0,0 @@ -source_skeleton_compare/CLI.py \ No newline at end of file diff --git a/tool/source_git-empty-dir/CLI.py b/tool/source_git-empty-dir/CLI.py new file mode 100755 index 0000000..2fb22e1 --- /dev/null +++ b/tool/source_git-empty-dir/CLI.py @@ -0,0 +1,251 @@ +#!/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 . + """ + 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-] + 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:])) diff --git a/tool/source_git-empty-dir/Harmony.py b/tool/source_git-empty-dir/Harmony.py new file mode 120000 index 0000000..112663e --- /dev/null +++ b/tool/source_git-empty-dir/Harmony.py @@ -0,0 +1 @@ +../source_sync/Harmony.py \ No newline at end of file diff --git a/tool/source_git-empty-dir/load_command_module.py b/tool/source_git-empty-dir/load_command_module.py new file mode 120000 index 0000000..87b98be --- /dev/null +++ b/tool/source_git-empty-dir/load_command_module.py @@ -0,0 +1 @@ +../source_sync/load_command_module.py \ No newline at end of file diff --git a/tool/source_git-empty-dir/meta.py b/tool/source_git-empty-dir/meta.py new file mode 100644 index 0000000..dee6439 --- /dev/null +++ b/tool/source_git-empty-dir/meta.py @@ -0,0 +1,97 @@ +#!/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 + ) diff --git a/tool/source_git-empty-dir/source_sync b/tool/source_git-empty-dir/source_sync new file mode 120000 index 0000000..9fd1d51 --- /dev/null +++ b/tool/source_git-empty-dir/source_sync @@ -0,0 +1 @@ +../source_sync/ \ No newline at end of file diff --git a/tool/source_skeleton_compare/A_minus_B b/tool/source_skeleton_compare/A_minus_B deleted file mode 100755 index f6f7bbb..0000000 --- a/tool/source_skeleton_compare/A_minus_B +++ /dev/null @@ -1,53 +0,0 @@ -#!/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 -""" - -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} ") - 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()) diff --git a/tool/source_skeleton_compare/CLI.py b/tool/source_skeleton_compare/CLI.py deleted file mode 100755 index f7fb0b0..0000000 --- a/tool/source_skeleton_compare/CLI.py +++ /dev/null @@ -1,282 +0,0 @@ -#!/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 * [] - - :: | | - - :: version | help | usage - :: environment - :: 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 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 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 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] = [] - - # 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 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 - ,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 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/source_skeleton_compare/GitIgnore.py b/tool/source_skeleton_compare/GitIgnore.py deleted file mode 100755 index 70c6509..0000000 --- a/tool/source_skeleton_compare/GitIgnore.py +++ /dev/null @@ -1,270 +0,0 @@ -#!/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 - . - - 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() -> token: - - - is a path relative to the project root. - - - We compute all prefix directories of , 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 - (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 /.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: - - -> [Pattern, Pattern, ...] - - where 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()) diff --git a/tool/source_skeleton_compare/Harmony.py b/tool/source_skeleton_compare/Harmony.py deleted file mode 100644 index 9385507..0000000 --- a/tool/source_skeleton_compare/Harmony.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/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 - diff --git a/tool/source_skeleton_compare/Harmony_where b/tool/source_skeleton_compare/Harmony_where deleted file mode 100755 index 9d39f1e..0000000 --- a/tool/source_skeleton_compare/Harmony_where +++ /dev/null @@ -1,37 +0,0 @@ -#!/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()) diff --git a/tool/source_skeleton_compare/README.org b/tool/source_skeleton_compare/README.org deleted file mode 100644 index 387780d..0000000 --- a/tool/source_skeleton_compare/README.org +++ /dev/null @@ -1,278 +0,0 @@ -#+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. - diff --git a/tool/source_skeleton_compare/command.py b/tool/source_skeleton_compare/command.py deleted file mode 100644 index 0ebb856..0000000 --- a/tool/source_skeleton_compare/command.py +++ /dev/null @@ -1,539 +0,0 @@ -#!/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: - * 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 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). - """ - 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 diff --git a/tool/source_skeleton_compare/doc.py b/tool/source_skeleton_compare/doc.py deleted file mode 100644 index 3198b96..0000000 --- a/tool/source_skeleton_compare/doc.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/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): - - * [] - - :: | | - - :: version | help | usage - :: environment - :: 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} * [] - -Where: - :: | | - - :: version | help | usage - :: environment - :: structure | import | export | suspicious | addendum | all -""" - -def _help_text(prog: str) -> str: - return f"""\ -{prog} - Harmony skeleton integrity and metadata checker - -Syntax: - {prog} * [] - -Where: - :: path - :: | | - - :: version | help | usage - :: environment - :: structure | import | export | suspicious | addendum | all - -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 directly from it. This is the 'default skeleton', or 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 - called 'B'. - - 4. If none of the commands require an path, then - must not be given. If at least one command requires , then - is required. Commands that require a path are called - commands. - - 5. Implementation detail: - All arguments except the final one are interpreted strictly as - command tokens. If any of those are , the final argument - is taken as . If none of the earlier tokens are , - the final argument is also treated as a command token. - -Roots: - A = Skeleton project root (auto-detected). Usually the Harmony skeleton. - B = 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") diff --git a/tool/source_skeleton_compare/in_between_and_below b/tool/source_skeleton_compare/in_between_and_below deleted file mode 100755 index 2993767..0000000 --- a/tool/source_skeleton_compare/in_between_and_below +++ /dev/null @@ -1,53 +0,0 @@ -#!/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 -""" - -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} ") - 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()) diff --git a/tool/source_skeleton_compare/load_command_module.py b/tool/source_skeleton_compare/load_command_module.py deleted file mode 100644 index 226b6dd..0000000 --- a/tool/source_skeleton_compare/load_command_module.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/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/ 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/ 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 diff --git a/tool/source_skeleton_compare/make_Harmony_tree_dict b/tool/source_skeleton_compare/make_Harmony_tree_dict deleted file mode 100755 index 2ed3cea..0000000 --- a/tool/source_skeleton_compare/make_Harmony_tree_dict +++ /dev/null @@ -1,48 +0,0 @@ -#!/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()) diff --git a/tool/source_skeleton_compare/manus_2025_11_20_215471873762383.txt b/tool/source_skeleton_compare/manus_2025_11_20_215471873762383.txt deleted file mode 100644 index 70e1f99..0000000 --- a/tool/source_skeleton_compare/manus_2025_11_20_215471873762383.txt +++ /dev/null @@ -1,33 +0,0 @@ -Conversation with Manus -Started on November 20, 2025 at 08:56 PM Singapore time +08 (GMT+0800) - ---- - -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. - -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) - -08:57 PM | Liora from Manus: We will respond to you with the resolution within 48 hours. Please wait for our update! - -09:01 PM | Thomas Lynch: You charged my $46.80, the plan says it costs $33.00 explain. - -09:02 PM | Thomas Lynch: hello? - ---- -Exported from Manus on November 20, 2025 at 09:03 PM Singapore time +08 (GMT+0800) \ No newline at end of file diff --git a/tool/source_skeleton_compare/meta.py b/tool/source_skeleton_compare/meta.py deleted file mode 100644 index 5c8da89..0000000 --- a/tool/source_skeleton_compare/meta.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/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 - ) diff --git a/tool/source_skeleton_compare/newer b/tool/source_skeleton_compare/newer deleted file mode 100755 index 30aa373..0000000 --- a/tool/source_skeleton_compare/newer +++ /dev/null @@ -1,53 +0,0 @@ -#!/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 -""" - -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} ") - 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()) diff --git a/tool/source_skeleton_compare/older b/tool/source_skeleton_compare/older deleted file mode 100755 index f8ff24d..0000000 --- a/tool/source_skeleton_compare/older +++ /dev/null @@ -1,53 +0,0 @@ -#!/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 -""" - -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} ") - 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()) diff --git a/tool/source_skeleton_compare/skeleton.py b/tool/source_skeleton_compare/skeleton.py deleted file mode 100644 index ae272f9..0000000 --- a/tool/source_skeleton_compare/skeleton.py +++ /dev/null @@ -1,576 +0,0 @@ -#!/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(, ) -> tree_dict -# -# () -> bignum | None -# -# Keys of tree_dict: -# - Relative paths from ; 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 , respecting GitIgnore. - - Semantics (current): - * Any path (directory or file) for which GitIgnore.check() - 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 - diff --git a/tool/source_sync/A_minus_B b/tool/source_sync/A_minus_B new file mode 100755 index 0000000..f6f7bbb --- /dev/null +++ b/tool/source_sync/A_minus_B @@ -0,0 +1,53 @@ +#!/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 +""" + +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} ") + 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()) diff --git a/tool/source_sync/CLI.py b/tool/source_sync/CLI.py new file mode 100755 index 0000000..f7fb0b0 --- /dev/null +++ b/tool/source_sync/CLI.py @@ -0,0 +1,282 @@ +#!/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 * [] + + :: | | + + :: version | help | usage + :: environment + :: 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 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 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 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] = [] + + # 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 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 + ,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 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/source_sync/GitIgnore.py b/tool/source_sync/GitIgnore.py new file mode 100755 index 0000000..70c6509 --- /dev/null +++ b/tool/source_sync/GitIgnore.py @@ -0,0 +1,270 @@ +#!/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 + . + + 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() -> token: + + - is a path relative to the project root. + + - We compute all prefix directories of , 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 + (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 /.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: + + -> [Pattern, Pattern, ...] + + where 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()) diff --git a/tool/source_sync/Harmony.py b/tool/source_sync/Harmony.py new file mode 100644 index 0000000..9385507 --- /dev/null +++ b/tool/source_sync/Harmony.py @@ -0,0 +1,133 @@ +#!/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 + diff --git a/tool/source_sync/Harmony_where b/tool/source_sync/Harmony_where new file mode 100755 index 0000000..9d39f1e --- /dev/null +++ b/tool/source_sync/Harmony_where @@ -0,0 +1,37 @@ +#!/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()) diff --git a/tool/source_sync/README.org b/tool/source_sync/README.org new file mode 100644 index 0000000..387780d --- /dev/null +++ b/tool/source_sync/README.org @@ -0,0 +1,278 @@ +#+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. + diff --git a/tool/source_sync/command.py b/tool/source_sync/command.py new file mode 100644 index 0000000..0ebb856 --- /dev/null +++ b/tool/source_sync/command.py @@ -0,0 +1,539 @@ +#!/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: + * 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 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). + """ + 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 diff --git a/tool/source_sync/doc.py b/tool/source_sync/doc.py new file mode 100644 index 0000000..3198b96 --- /dev/null +++ b/tool/source_sync/doc.py @@ -0,0 +1,182 @@ +#!/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): + + * [] + + :: | | + + :: version | help | usage + :: environment + :: 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} * [] + +Where: + :: | | + + :: version | help | usage + :: environment + :: structure | import | export | suspicious | addendum | all +""" + +def _help_text(prog: str) -> str: + return f"""\ +{prog} - Harmony skeleton integrity and metadata checker + +Syntax: + {prog} * [] + +Where: + :: path + :: | | + + :: version | help | usage + :: environment + :: structure | import | export | suspicious | addendum | all + +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 directly from it. This is the 'default skeleton', or 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 + called 'B'. + + 4. If none of the commands require an path, then + must not be given. If at least one command requires , then + is required. Commands that require a path are called + commands. + + 5. Implementation detail: + All arguments except the final one are interpreted strictly as + command tokens. If any of those are , the final argument + is taken as . If none of the earlier tokens are , + the final argument is also treated as a command token. + +Roots: + A = Skeleton project root (auto-detected). Usually the Harmony skeleton. + B = 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") diff --git a/tool/source_sync/in_between_and_below b/tool/source_sync/in_between_and_below new file mode 100755 index 0000000..2993767 --- /dev/null +++ b/tool/source_sync/in_between_and_below @@ -0,0 +1,53 @@ +#!/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 +""" + +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} ") + 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()) diff --git a/tool/source_sync/load_command_module.py b/tool/source_sync/load_command_module.py new file mode 100644 index 0000000..226b6dd --- /dev/null +++ b/tool/source_sync/load_command_module.py @@ -0,0 +1,103 @@ +#!/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/ 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/ 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 diff --git a/tool/source_sync/make_Harmony_tree_dict b/tool/source_sync/make_Harmony_tree_dict new file mode 100755 index 0000000..2ed3cea --- /dev/null +++ b/tool/source_sync/make_Harmony_tree_dict @@ -0,0 +1,48 @@ +#!/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()) diff --git a/tool/source_sync/manus_2025_11_20_215471873762383.txt b/tool/source_sync/manus_2025_11_20_215471873762383.txt new file mode 100644 index 0000000..70e1f99 --- /dev/null +++ b/tool/source_sync/manus_2025_11_20_215471873762383.txt @@ -0,0 +1,33 @@ +Conversation with Manus +Started on November 20, 2025 at 08:56 PM Singapore time +08 (GMT+0800) + +--- + +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. + +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) + +08:57 PM | Liora from Manus: We will respond to you with the resolution within 48 hours. Please wait for our update! + +09:01 PM | Thomas Lynch: You charged my $46.80, the plan says it costs $33.00 explain. + +09:02 PM | Thomas Lynch: hello? + +--- +Exported from Manus on November 20, 2025 at 09:03 PM Singapore time +08 (GMT+0800) \ No newline at end of file diff --git a/tool/source_sync/meta.py b/tool/source_sync/meta.py new file mode 100644 index 0000000..5c8da89 --- /dev/null +++ b/tool/source_sync/meta.py @@ -0,0 +1,97 @@ +#!/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 + ) diff --git a/tool/source_sync/newer b/tool/source_sync/newer new file mode 100755 index 0000000..30aa373 --- /dev/null +++ b/tool/source_sync/newer @@ -0,0 +1,53 @@ +#!/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 +""" + +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} ") + 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()) diff --git a/tool/source_sync/older b/tool/source_sync/older new file mode 100755 index 0000000..f8ff24d --- /dev/null +++ b/tool/source_sync/older @@ -0,0 +1,53 @@ +#!/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 +""" + +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} ") + 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()) diff --git a/tool/source_sync/skeleton.py b/tool/source_sync/skeleton.py new file mode 100644 index 0000000..ae272f9 --- /dev/null +++ b/tool/source_sync/skeleton.py @@ -0,0 +1,576 @@ +#!/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(, ) -> tree_dict +# +# () -> bignum | None +# +# Keys of tree_dict: +# - Relative paths from ; 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 , respecting GitIgnore. + + Semantics (current): + * Any path (directory or file) for which GitIgnore.check() + 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 + diff --git a/tool/sync b/tool/sync new file mode 120000 index 0000000..fc351d9 --- /dev/null +++ b/tool/sync @@ -0,0 +1 @@ +source_sync/CLI.py \ No newline at end of file diff --git a/tool_2.tar b/tool_2.tar new file mode 100644 index 0000000..e444953 Binary files /dev/null and b/tool_2.tar differ