From df52f7b9e24e22fed5f33daab03dd660c6396c3c Mon Sep 17 00:00:00 2001 From: Thomas Walker Lynch Date: Thu, 8 Jan 2026 13:56:20 +0000 Subject: [PATCH] Initial commit: Epimetheus structure from Harmony template --- .gitignore | 12 + 0pus_Epimetheus | 0 CONTRIBUTING.md | 12 + LICENSE | 21 + README.md | 2 + developer/authored/Identity.py | 42 ++ developer/authored/ObjectRegistry.py | 69 +++ developer/authored/ProcessLocalId.py | 52 +++ developer/authored/Property.py | 29 ++ developer/authored/PropertyManager.py | 155 +++++++ developer/authored/PropertyStore.py | 59 +++ developer/authored/SemanticSets.py | 55 +++ developer/authored/Syntax.py | 59 +++ developer/authored/__init__.py | 15 + developer/authored/hello.cli.c | 2 + .../authored/property_manager_example_1.py | 60 +++ developer/document/.gitkeep | 0 developer/document/ontology.org | 45 ++ developer/experiment/.gitkeep | 0 developer/made/.gitkeep | 0 developer/scratchpad/.gitignore | 2 + developer/tool/.gitkeep | 0 developer/tool/env | 3 + developer/tool/make | 19 + developer/tool/makefile | 54 +++ developer/tool/promote | 287 ++++++++++++ document/.gitkeep | 0 document/Harmony/00_Project_Structure.org | 162 +++++++ .../01_Workflow_and_Build_Contract.org | 146 ++++++ document/Harmony/02_RT_Code_Format.org | 230 ++++++++++ .../03_Naming_and_Directory_Conventions.org | 33 ++ document/Harmony/04_Language_Addenda.org | 170 +++++++ document/Harmony/style/rt_dark_doc.css | 44 ++ env_developer | 44 ++ env_tester | 44 ++ env_toolsmith | 45 ++ nohup.out | 0 release/authored/.gitkeep | 0 release/documnt/.gitkeep | 0 release/made_tracked/.gitkeep | 0 release/made_untracked/.gitignore | 3 + release/tool/.gitkeep | 0 scratchpad/.gitignore | 2 + .../authored/deprecated/git-empty-dir/CLI.py | 251 +++++++++++ .../deprecated/git-empty-dir/Harmony.py | 1 + .../git-empty-dir/load_command_module.py | 1 + .../authored/deprecated/git-empty-dir/meta.py | 97 ++++ .../deprecated/git-empty-dir/source_sync | 1 + .../authored/deprecated/gitignore_treewalk.py | 185 ++++++++ shared/authored/deprecated/walk | 1 + .../walk-dir-tree-w-gitignore/CLI.py | 91 ++++ .../walk-dir-tree-w-gitignore/__init__.py | 15 + .../walk-dir-tree-w-gitignore/pattern.py | 115 +++++ .../walk-dir-tree-w-gitignore/printer.py | 38 ++ .../walk-dir-tree-w-gitignore/ruleset.py | 57 +++ .../walk-dir-tree-w-gitignore/walker.py | 121 +++++ shared/authored/env | 130 ++++++ shared/authored/sys | 0 shared/authored/version | 2 + shared/authored_10.zip | Bin 0 -> 77199 bytes shared/document/.gitkeep | 0 shared/document/install_Python.org | 75 ++++ shared/document/install_generic.org | 81 ++++ shared/made/walk | 1 + shared/third_party/.gitignore | 8 + shared/third_party/upstream/.gitignore | 2 + temp.sh | 11 + tester/.gitkeep | 0 tester/RT_Format/RT_Format | 415 ++++++++++++++++++ tester/RT_Format/RT_Format.el | 4 + tester/RT_Format/test_0_data.c | 15 + tester/RT_Format/test_1_data.py | 16 + tester/tool/env | 3 + tool/Harmony_sync | 1 + tool/after_pull | 124 ++++++ tool/env | 3 + tool/git-tar | 280 ++++++++++++ tool/release | 291 ++++++++++++ 78 files changed, 4413 insertions(+) create mode 100644 .gitignore create mode 100644 0pus_Epimetheus create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 developer/authored/Identity.py create mode 100644 developer/authored/ObjectRegistry.py create mode 100644 developer/authored/ProcessLocalId.py create mode 100644 developer/authored/Property.py create mode 100644 developer/authored/PropertyManager.py create mode 100644 developer/authored/PropertyStore.py create mode 100644 developer/authored/SemanticSets.py create mode 100644 developer/authored/Syntax.py create mode 100644 developer/authored/__init__.py create mode 100644 developer/authored/hello.cli.c create mode 100755 developer/authored/property_manager_example_1.py create mode 100644 developer/document/.gitkeep create mode 100644 developer/document/ontology.org create mode 100644 developer/experiment/.gitkeep create mode 100644 developer/made/.gitkeep create mode 100644 developer/scratchpad/.gitignore create mode 100644 developer/tool/.gitkeep create mode 100644 developer/tool/env create mode 100755 developer/tool/make create mode 100644 developer/tool/makefile create mode 100755 developer/tool/promote create mode 100644 document/.gitkeep create mode 100644 document/Harmony/00_Project_Structure.org create mode 100644 document/Harmony/01_Workflow_and_Build_Contract.org create mode 100644 document/Harmony/02_RT_Code_Format.org create mode 100644 document/Harmony/03_Naming_and_Directory_Conventions.org create mode 100644 document/Harmony/04_Language_Addenda.org create mode 100644 document/Harmony/style/rt_dark_doc.css create mode 100644 env_developer create mode 100644 env_tester create mode 100644 env_toolsmith create mode 100644 nohup.out create mode 100644 release/authored/.gitkeep create mode 100644 release/documnt/.gitkeep create mode 100644 release/made_tracked/.gitkeep create mode 100644 release/made_untracked/.gitignore create mode 100644 release/tool/.gitkeep create mode 100644 scratchpad/.gitignore create mode 100755 shared/authored/deprecated/git-empty-dir/CLI.py create mode 120000 shared/authored/deprecated/git-empty-dir/Harmony.py create mode 120000 shared/authored/deprecated/git-empty-dir/load_command_module.py create mode 100644 shared/authored/deprecated/git-empty-dir/meta.py create mode 120000 shared/authored/deprecated/git-empty-dir/source_sync create mode 100755 shared/authored/deprecated/gitignore_treewalk.py create mode 120000 shared/authored/deprecated/walk create mode 100755 shared/authored/deprecated/walk-dir-tree-w-gitignore/CLI.py create mode 100644 shared/authored/deprecated/walk-dir-tree-w-gitignore/__init__.py create mode 100644 shared/authored/deprecated/walk-dir-tree-w-gitignore/pattern.py create mode 100644 shared/authored/deprecated/walk-dir-tree-w-gitignore/printer.py create mode 100644 shared/authored/deprecated/walk-dir-tree-w-gitignore/ruleset.py create mode 100644 shared/authored/deprecated/walk-dir-tree-w-gitignore/walker.py create mode 100644 shared/authored/env create mode 100644 shared/authored/sys create mode 100644 shared/authored/version create mode 100644 shared/authored_10.zip create mode 100644 shared/document/.gitkeep create mode 100644 shared/document/install_Python.org create mode 100644 shared/document/install_generic.org create mode 120000 shared/made/walk create mode 100644 shared/third_party/.gitignore create mode 100644 shared/third_party/upstream/.gitignore create mode 100644 temp.sh create mode 100644 tester/.gitkeep create mode 100755 tester/RT_Format/RT_Format create mode 100644 tester/RT_Format/RT_Format.el create mode 100644 tester/RT_Format/test_0_data.c create mode 100644 tester/RT_Format/test_1_data.py create mode 100644 tester/tool/env create mode 120000 tool/Harmony_sync create mode 100755 tool/after_pull create mode 100644 tool/env create mode 100755 tool/git-tar create mode 100755 tool/release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff01a07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.ipynb_checkpoints/ +.pytest_cache/ + +# editor backup files (optional) +*~ +*.bak + diff --git a/0pus_Epimetheus b/0pus_Epimetheus new file mode 100644 index 0000000..e69de29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ff8f734 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,12 @@ +# Contributing + +By contributing, you agree your contributions are licensed under the repository +SPDX license expression: + + MIT + +We use "inbound = outbound": you retain copyright; you license your +contribution under the same terms. Optionally sign off commits per the DCO: + + Signed-off-by: Your Name + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4069b76 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019, 2024, 2025 Reasoning Technology + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7104a94 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Epimetheus + diff --git a/developer/authored/Identity.py b/developer/authored/Identity.py new file mode 100644 index 0000000..236ef8a --- /dev/null +++ b/developer/authored/Identity.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + + +""" +Identity + +An abstract identity used as the subject key for property attachment. + +Kinds (strings) determine storage and resolution behavior. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any ,Optional ,Tuple + +from .ProcessLocalId import ProcessLocalId + + +IDENTITY_KIND_PY_OBJECT = "py_object" +IDENTITY_KIND_SYNTAX = "syntax" +IDENTITY_KIND_PROPERTY = "property" +IDENTITY_KIND_SET = "semantic_set" + + +@dataclass(frozen=True ,slots=True) +class Identity: + """ + `id` is always a ProcessLocalId. + + `kind` partitions lookup behavior. + + `payload` is kind-specific metadata (kept small; do not put giant graphs here). + """ + id: ProcessLocalId + kind: str + payload: Any = None + + def __repr__(self) -> str: + # Do not reveal id token. + return f"" diff --git a/developer/authored/ObjectRegistry.py b/developer/authored/ObjectRegistry.py new file mode 100644 index 0000000..b3e4cab --- /dev/null +++ b/developer/authored/ObjectRegistry.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + + +""" +ObjectRegistry + +Maps Python runtime objects to ProcessLocalId using weak identity. + +Constraints: + - Only weakref-able Python objects can be registered. + - This is intentional: RT properties attach to identity-bearing runtime instances, + not to value-like primitives (ints/strings/lists/dicts). +""" + +from __future__ import annotations + +import weakref +from typing import Any ,Callable ,Dict ,Optional + +from .ProcessLocalId import ProcessLocalIdGenerator ,ProcessLocalId + + +class ObjectRegistry: + def __init__(self ,id_gen: ProcessLocalIdGenerator): + self._id_gen = id_gen + self._obj_to_id_wkd: "weakref.WeakKeyDictionary[Any ,ProcessLocalId]" = weakref.WeakKeyDictionary() + self._id_to_obj_ref: Dict[ProcessLocalId ,weakref.ref] = {} + self._finalizers: Dict[ProcessLocalId ,Callable[[ProcessLocalId],None]] = {} + + def register_finalizer(self ,fn: Callable[[ProcessLocalId],None]): + """ + Registers a finalizer callback invoked when any registered object is GC'd. + """ + self._global_finalizer = fn + + def _on_collect(self ,obj_id: ProcessLocalId): + self._obj_to_id_wkd.pop(self._id_to_obj_ref[obj_id]() ,None) + self._id_to_obj_ref.pop(obj_id ,None) + fn = getattr(self ,"_global_finalizer" ,None) + if fn is not None: fn(obj_id) + + def get_id(self ,obj: Any) -> ProcessLocalId: + """ + Returns the ProcessLocalId for `obj`, registering it if needed. + + Raises TypeError if `obj` is not weakref-able. + """ + try: + existing = self._obj_to_id_wkd.get(obj) + except TypeError: + raise TypeError("ObjectRegistry: object is not weakref-able; RT properties do not attach to value-like primitives.") + if existing is not None: return existing + + obj_id = self._id_gen.next_id() + try: + self._obj_to_id_wkd[obj] = obj_id + except TypeError: + raise TypeError("ObjectRegistry: object is not weakref-able; RT properties do not attach to value-like primitives.") + self._id_to_obj_ref[obj_id] = weakref.ref(obj ,lambda _ref ,oid=obj_id: self._on_collect(oid)) + return obj_id + + def try_get_object(self ,obj_id: ProcessLocalId) -> Optional[Any]: + """ + Best-effort: returns the live object, or None if it has been collected or never registered. + """ + ref = self._id_to_obj_ref.get(obj_id) + if ref is None: return None + return ref() diff --git a/developer/authored/ProcessLocalId.py b/developer/authored/ProcessLocalId.py new file mode 100644 index 0000000..b57c47c --- /dev/null +++ b/developer/authored/ProcessLocalId.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + + +""" +ProcessLocalId + +A process-local identifier used as an internal key. + +Design constraint: + - NOT intended to be serialized or persisted. + - `repr()` intentionally does not reveal the numeric token, to discourage logging/persistence. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True ,slots=True) +class ProcessLocalId: + _n: int + + def __repr__(self) -> str: + return "" + + def __str__(self) -> str: + return "" + + def as_int_UNSAFE(self) -> int: + """ + Returns the raw integer token. + + UNSAFE because: + - tokens are process-local + - do not write these into files/databases/logs as stable identifiers + """ + return self._n + + +class ProcessLocalIdGenerator: + """ + Monotonic generator; ids are never recycled. + """ + def __init__(self ,start: int = 1): + if start < 1: raise ValueError("start must be >= 1") + self._next_n: int = start + + def next_id(self) -> ProcessLocalId: + n = self._next_n + self._next_n += 1 + return ProcessLocalId(n) diff --git a/developer/authored/Property.py b/developer/authored/Property.py new file mode 100644 index 0000000..5b33226 --- /dev/null +++ b/developer/authored/Property.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + + +""" +Property + +A Property is itself an entity (it has an Identity id) so that: + - properties can have properties + - properties can be members of semantic sets +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional ,Tuple + +from .ProcessLocalId import ProcessLocalId + + +@dataclass(frozen=True ,slots=True) +class Property: + id: ProcessLocalId + name_path: Tuple[str ,...] + doc: str = "" + + def __repr__(self) -> str: + # name_path is safe to reveal; id token is not. + return f"" diff --git a/developer/authored/PropertyManager.py b/developer/authored/PropertyManager.py new file mode 100644 index 0000000..36e2b9e --- /dev/null +++ b/developer/authored/PropertyManager.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + + +""" +PropertyManager + +Core RT property system. + +Key decisions vs the earlier `property_manager.py`: + - do NOT key by `object_path()` strings (avoids collisions) fileciteturn3file4 + - runtime objects are keyed by weak identity (ProcessLocalId assigned by ObjectRegistry) + - properties are first-class entities (Property has an id), so properties can have properties + +This remains process-local and in-memory. +""" + +from __future__ import annotations + +from typing import Any ,Dict ,Iterable ,List ,Optional ,Tuple ,Union + +from .ProcessLocalId import ProcessLocalIdGenerator ,ProcessLocalId +from .ObjectRegistry import ObjectRegistry +from .PropertyStore import PropertyStore +from .Property import Property +from .SemanticSets import SemanticSet ,SemanticSetStore +from .Syntax import SyntaxInstance + + +NamePathLike = Union[str ,List[str] ,Tuple[str ,...]] + + +class PropertyManager: + def __init__(self): + self._id_gen = ProcessLocalIdGenerator() + self._obj_reg = ObjectRegistry(self._id_gen) + self._store = PropertyStore() + self._sets = SemanticSetStore() + + # Declare-by-name registry + self._name_path_to_property: Dict[Tuple[str ,...],Property] = {} + self._property_id_to_property: Dict[ProcessLocalId ,Property] = {} + + self._name_path_to_set: Dict[Tuple[str ,...],SemanticSet] = {} + self._set_id_to_set: Dict[ProcessLocalId ,SemanticSet] = {} + + # Optional syntax instances (if user chooses to model them) + self._syntax_id_to_instance: Dict[ProcessLocalId ,SyntaxInstance] = {} + + # Finalization cleanup + self._obj_reg.register_finalizer(self._on_subject_finalized) + + def _on_subject_finalized(self ,subject_id: ProcessLocalId): + self._store.remove_subject(subject_id) + self._sets.remove_subject(subject_id) + + def _normalize_name_path(self ,name_path: NamePathLike) -> Tuple[str ,...]: + if isinstance(name_path ,tuple): return name_path + if isinstance(name_path ,list): return tuple(name_path) + if isinstance(name_path ,str): return tuple(name_path.split(".")) + raise TypeError("name_path must be str ,list[str] ,or tuple[str ,...]") + + # ------------------------- + # Identity acquisition + # ------------------------- + def id_of_py_object(self ,obj: Any) -> ProcessLocalId: + return self._obj_reg.get_id(obj) + + def create_syntax_identity(self ,syntax: SyntaxInstance) -> ProcessLocalId: + sid = self._id_gen.next_id() + self._syntax_id_to_instance[sid] = syntax + return sid + + def try_get_syntax(self ,syntax_id: ProcessLocalId) -> Optional[SyntaxInstance]: + return self._syntax_id_to_instance.get(syntax_id) + + # ------------------------- + # Property declaration + # ------------------------- + def declare_property(self ,name_path: NamePathLike ,doc: str = "") -> ProcessLocalId: + np = self._normalize_name_path(name_path) + existing = self._name_path_to_property.get(np) + if existing is not None: return existing.id + pid = self._id_gen.next_id() + p = Property(pid ,np ,doc) + self._name_path_to_property[np] = p + self._property_id_to_property[pid] = p + return pid + + def property_id(self ,name_path: NamePathLike) -> ProcessLocalId: + np = self._normalize_name_path(name_path) + p = self._name_path_to_property.get(np) + if p is None: raise KeyError(f"Property not declared: {np!r}") + return p.id + + def try_get_property(self ,prop_id: ProcessLocalId) -> Optional[Property]: + return self._property_id_to_property.get(prop_id) + + # ------------------------- + # Semantic sets + # ------------------------- + def declare_set(self ,name_path: NamePathLike ,doc: str = "") -> ProcessLocalId: + np = self._normalize_name_path(name_path) + existing = self._name_path_to_set.get(np) + if existing is not None: return existing.id + sid = self._id_gen.next_id() + s = SemanticSet(sid ,np ,doc) + self._name_path_to_set[np] = s + self._set_id_to_set[sid] = s + return sid + + def add_to_set(self ,subject: Any ,set_id: ProcessLocalId): + subject_id = self._coerce_subject_id(subject) + self._sets.add_member(set_id ,subject_id) + + def is_in_set(self ,subject: Any ,set_id: ProcessLocalId) -> bool: + subject_id = self._coerce_subject_id(subject) + return self._sets.has_member(set_id ,subject_id) + + def members(self ,set_id: ProcessLocalId) -> List[ProcessLocalId]: + return list(self._sets.members(set_id)) + + # ------------------------- + # Set/get properties + # ------------------------- + def set(self ,subject: Any ,prop: Union[ProcessLocalId ,NamePathLike] ,value: Any): + subject_id = self._coerce_subject_id(subject) + prop_id = self._coerce_property_id(prop) + self._store.set(subject_id ,prop_id ,value) + + def get(self ,subject: Any ,prop: Union[ProcessLocalId ,NamePathLike] ,default: Any = None) -> Any: + subject_id = self._coerce_subject_id(subject) + prop_id = self._coerce_property_id(prop) + return self._store.get(subject_id ,prop_id ,default) + + def has(self ,subject: Any ,prop: Union[ProcessLocalId ,NamePathLike]) -> bool: + subject_id = self._coerce_subject_id(subject) + prop_id = self._coerce_property_id(prop) + return self._store.has(subject_id ,prop_id) + + def subjects_with(self ,prop: Union[ProcessLocalId ,NamePathLike]) -> List[ProcessLocalId]: + prop_id = self._coerce_property_id(prop) + return list(self._store.subjects_with(prop_id)) + + # ------------------------- + # Coercions + # ------------------------- + def _coerce_subject_id(self ,subject: Any) -> ProcessLocalId: + if isinstance(subject ,ProcessLocalId): return subject + # For Python runtime objects, we require weakref-able instances. + return self._obj_reg.get_id(subject) + + def _coerce_property_id(self ,prop: Union[ProcessLocalId ,NamePathLike]) -> ProcessLocalId: + if isinstance(prop ,ProcessLocalId): return prop + return self.property_id(prop) diff --git a/developer/authored/PropertyStore.py b/developer/authored/PropertyStore.py new file mode 100644 index 0000000..bc8f3f3 --- /dev/null +++ b/developer/authored/PropertyStore.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + + +""" +PropertyStore + +Stores property values and maintains reverse lookups. + +This is intentionally process-local and in-memory. +""" + +from __future__ import annotations + +from typing import Any ,Dict ,Optional ,Set ,Tuple + +from .ProcessLocalId import ProcessLocalId + + +class PropertyStore: + def __init__(self): + # (subject_id ,property_id) -> value + self._values: Dict[Tuple[ProcessLocalId ,ProcessLocalId] ,Any] = {} + + # subject_id -> set(property_id) + self._subject_to_props: Dict[ProcessLocalId ,Set[ProcessLocalId]] = {} + + # property_id -> set(subject_id) + self._prop_to_subjects: Dict[ProcessLocalId ,Set[ProcessLocalId]] = {} + + def set(self ,subject_id: ProcessLocalId ,prop_id: ProcessLocalId ,value: Any): + key = (subject_id ,prop_id) + self._values[key] = value + self._subject_to_props.setdefault(subject_id ,set()).add(prop_id) + self._prop_to_subjects.setdefault(prop_id ,set()).add(subject_id) + + def get(self ,subject_id: ProcessLocalId ,prop_id: ProcessLocalId ,default: Any = None) -> Any: + return self._values.get((subject_id ,prop_id) ,default) + + def has(self ,subject_id: ProcessLocalId ,prop_id: ProcessLocalId) -> bool: + return (subject_id ,prop_id) in self._values + + def subjects_with(self ,prop_id: ProcessLocalId) -> Set[ProcessLocalId]: + return set(self._prop_to_subjects.get(prop_id ,set())) + + def props_of(self ,subject_id: ProcessLocalId) -> Set[ProcessLocalId]: + return set(self._subject_to_props.get(subject_id ,set())) + + def remove_subject(self ,subject_id: ProcessLocalId): + """ + Remove all stored properties for a subject (used on finalization). + """ + prop_ids = self._subject_to_props.pop(subject_id ,set()) + for prop_id in prop_ids: + self._values.pop((subject_id ,prop_id) ,None) + s = self._prop_to_subjects.get(prop_id) + if s is not None: + s.discard(subject_id) + if not s: self._prop_to_subjects.pop(prop_id ,None) diff --git a/developer/authored/SemanticSets.py b/developer/authored/SemanticSets.py new file mode 100644 index 0000000..f0fdf48 --- /dev/null +++ b/developer/authored/SemanticSets.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + + +""" +SemanticSets + +Membership sets over identities. Used for semantic typing. + +Design: + - set_id identifies the set + - members are subject ids + - reverse index for cleanup +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict ,Optional ,Set + +from .ProcessLocalId import ProcessLocalId + + +@dataclass(frozen=True ,slots=True) +class SemanticSet: + id: ProcessLocalId + name_path: tuple[str ,...] + doc: str = "" + + def __repr__(self) -> str: + return f"" + + +class SemanticSetStore: + def __init__(self): + self._members: Dict[ProcessLocalId ,Set[ProcessLocalId]] = {} + self._subject_to_sets: Dict[ProcessLocalId ,Set[ProcessLocalId]] = {} + + def add_member(self ,set_id: ProcessLocalId ,subject_id: ProcessLocalId): + self._members.setdefault(set_id ,set()).add(subject_id) + self._subject_to_sets.setdefault(subject_id ,set()).add(set_id) + + def has_member(self ,set_id: ProcessLocalId ,subject_id: ProcessLocalId) -> bool: + return subject_id in self._members.get(set_id ,set()) + + def members(self ,set_id: ProcessLocalId) -> Set[ProcessLocalId]: + return set(self._members.get(set_id ,set())) + + def remove_subject(self ,subject_id: ProcessLocalId): + set_ids = self._subject_to_sets.pop(subject_id ,set()) + for set_id in set_ids: + m = self._members.get(set_id) + if m is not None: + m.discard(subject_id) + if not m: self._members.pop(set_id ,None) diff --git a/developer/authored/Syntax.py b/developer/authored/Syntax.py new file mode 100644 index 0000000..4fcfa7c --- /dev/null +++ b/developer/authored/Syntax.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + + +""" +Syntax + +RT syntax identity instances. + +We treat "syntax" as AST-level objects: + - kind: official-ish AST node kind name (e.g., "ast.FunctionDef") + - location: file + span + - scope: enclosing syntax identity id (optional) + - parts: mapping of part-name to literal or referenced syntax identity id(s) + +This module does NOT traverse Python programs. It only defines the data model. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any ,Dict ,Optional ,Tuple ,Union ,List + +from .ProcessLocalId import ProcessLocalId + + +@dataclass(frozen=True ,slots=True) +class SourceSpan: + file_path: str + lineno: int + col: int + end_lineno: int + end_col: int + + +SyntaxPartValue = Union[ + None + ,bool + ,int + ,float + ,str + ,ProcessLocalId + ,List["SyntaxPartValue"] + ,Dict[str ,"SyntaxPartValue"] +] + + +@dataclass(frozen=True ,slots=True) +class SyntaxInstance: + """ + A single syntax node instance. + + NOTE: many syntax nodes have no identifier-name. Name-like things (identifiers) + appear as child nodes or literals inside `parts`. + """ + kind: str + span: SourceSpan + scope_id: Optional[ProcessLocalId] = None + parts: Dict[str ,SyntaxPartValue] = None diff --git a/developer/authored/__init__.py b/developer/authored/__init__.py new file mode 100644 index 0000000..2b53823 --- /dev/null +++ b/developer/authored/__init__.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + +""" +rt_property_manager + +Process-local property attachment with: + - weak identity for Python runtime instances + - explicit identities for syntax instances and properties + +Notes: + - ProcessLocalId values are not meant to be serialized or persisted. +""" + +from .PropertyManager import PropertyManager diff --git a/developer/authored/hello.cli.c b/developer/authored/hello.cli.c new file mode 100644 index 0000000..a626cac --- /dev/null +++ b/developer/authored/hello.cli.c @@ -0,0 +1,2 @@ +#include +int main(void){ puts("hello from Rabbit CLI"); return 0; } diff --git a/developer/authored/property_manager_example_1.py b/developer/authored/property_manager_example_1.py new file mode 100755 index 0000000..aab72e4 --- /dev/null +++ b/developer/authored/property_manager_example_1.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + + +""" +property_manager_example_1.py + +Demonstrate RT identity-based PropertyManager. + +Run: + PYTHONPATH=. python3 property_manager_example_1.py +""" + +from rt_property_manager import PropertyManager + + +class WidgetFactory: + def __call__(self ,x): + return Widget(x) + + +class Widget: + def __init__(self ,x): + self.x = x + + def add(self ,y): + return self.x + y + + +def main(): + pm = PropertyManager() + + # Declare semantic set "WidgetFactories" + set_widget_factories_id = pm.declare_set(["semantic" ,"WidgetFactories"] ,"Factories that produce Widgets") + + wf = WidgetFactory() + pm.add_to_set(wf ,set_widget_factories_id) + + # Declare a property "printer" (intended to attach to methods) + prop_printer_id = pm.declare_property(["semantic" ,"printer"] ,"Callable that prints the value of an instance") + + # Attach property to Widget.add method object (unbound function attribute on class) + pm.set(Widget.add ,prop_printer_id ,lambda inst: print(f"Widget(x={inst.x})")) + + w = wf(7) + + # Semantic check: require that the provenance factory is in the WidgetFactories set + # (In this example we didn't record provenance; we'd do that via an explicit call later.) + + # Call printer property on the method we care about + printer = pm.get(Widget.add ,prop_printer_id) + printer(w) + + # Reverse lookup: which subjects have 'printer'? + subject_ids = pm.subjects_with(prop_printer_id) + print("subjects_with(printer):" ,len(subject_ids)) + + +if __name__ == "__main__": + main() diff --git a/developer/document/.gitkeep b/developer/document/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/developer/document/ontology.org b/developer/document/ontology.org new file mode 100644 index 0000000..a7a76a8 --- /dev/null +++ b/developer/document/ontology.org @@ -0,0 +1,45 @@ +#+TITLE: Project Ontology: Authored vs. Loadable +#+AUTHOR: Harmony Developer +#+DATE: 2025-11-21 +#+OPTIONS: toc:t num:nil + +* The Core Philosophy +This project distinguishes files based on fundamental **invariants** (properties) rather than arbitrary file types[cite: 889, 890]. This creates a clear semantic structure: +- **Provenance**: Who created this file? +- **Capability**: What is the file's primary function in the system? [cite: 890, 891] + +**The Golden Rule:** God and Artists (Developers) *create* things; Factories (Build Systems) *make* things[cite: 892]. + +* Directory Structure Overview + +#+BEGIN_SRC text +developer/ +├── authored/ # (The Logic) Human-written source code. +├── loadable/ # (Capability) Agnostic Entry Points. +├── scratchpad/ # (Transient) Intermediates, Objects. +└── tool/ # (The Factory) Build scripts. + +release/ +├── loadable/ # (Capability) Shared, Agnostic Release Entry Points. +├── local_build/ # (Action/Locality) Architecture-specific binaries. +#+END_SRC + +* Detailed Invariants + +** ~developer/authored/~ +- **Invariant:** Primary Logic Source (Code). Every file here is written by a human author[cite: 898, 899]. +- **Rule:** Scripts must treat this directory as read-only. This replaces the old `cc/` and `python3/` source directories. + +** ~developer/loadable/~ +- **Invariant:** Architecture-Agnostic Entry Points. Files here possess the property of being executable by the user[cite: 902]. +- **Contents:** Symlinks to interpreted code, shared scripts, and wrappers that are safe to commit[cite: 903]. + +** ~developer/scratchpad/loadable/~ +- **Invariant:** Machine-Generated Executables (Intermediate). This is the transient output location during development[cite: 904]. +- **Rule:** This directory is **ignored by Git** and houses compiled binaries and libraries derived by the build system[cite: 905, 906]. + +** ~release/local_build/~ +- **Invariant:** Local Action Required. +- **Rule:** This directory is added to the `.gitignore` in the release directory. Its presence signals to a new developer: **"You must perform a local build to populate this directory with machine-specific executables."** +- **Contents:** Final binaries are copied here by the release script from the `scratchpad/`[cite: 915]. + < diff --git a/developer/experiment/.gitkeep b/developer/experiment/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/developer/made/.gitkeep b/developer/made/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/developer/scratchpad/.gitignore b/developer/scratchpad/.gitignore new file mode 100644 index 0000000..120f485 --- /dev/null +++ b/developer/scratchpad/.gitignore @@ -0,0 +1,2 @@ +* +!/.gitignore diff --git a/developer/tool/.gitkeep b/developer/tool/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/developer/tool/env b/developer/tool/env new file mode 100644 index 0000000..0b993ad --- /dev/null +++ b/developer/tool/env @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +script_afp=$(realpath "${BASH_SOURCE[0]}") + diff --git a/developer/tool/make b/developer/tool/make new file mode 100755 index 0000000..dbd1f15 --- /dev/null +++ b/developer/tool/make @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +script_afp=$(realpath "${BASH_SOURCE[0]}") + +# input guards + + env_must_be="developer/tool/env" + if [ "$ENV" != "$env_must_be" ]; then + echo "$(script_fp):: error: must be run in the $env_must_be environment" + exit 1 + fi + +set -e +set -x + + cd "$REPO_HOME"/developer || exit 1 + /bin/make -f tool/makefile $@ + +set +x +echo "$(script_fn) done." diff --git a/developer/tool/makefile b/developer/tool/makefile new file mode 100644 index 0000000..65408ed --- /dev/null +++ b/developer/tool/makefile @@ -0,0 +1,54 @@ +# developer/tool/makefile — Orchestrator (Hybrid) +.SUFFIXES: +.EXPORT_ALL_VARIABLES: + +RT_INCOMMON := $(REPO_HOME)/shared/third_party/RT-project-share/release +include $(RT_INCOMMON)/make/environment_RT_1.mk + +.PHONY: usage +usage: + @printf "Usage: make [usage|information|all|lib|CLI|kmod|clean]\n" + +.PHONY: version +version: + @printf "local ----------------------------------------\n" + @echo tool/makefile version 2.0 + @printf "target_library_CLI.mk ----------------------------------------\n" + @$(MAKE) -f $(RT_INCOMMON)/make/target_kmod.mk version + @printf "target_kmod.mk ----------------------------------------\n" + @$(MAKE) -f $(RT_INCOMMON)/make/target_library_CLI.mk version + +.PHONY: information +information: + @printf "local ----------------------------------------\n" + -@echo CURDIR='$(CURDIR)' + @echo REPO_HOME="$(REPO_HOME)" + @echo KMOD_BUILD_DIR="/lib/modules/$(shell uname -r)/build" + @echo CURDIR="$(CURDIR)" + @printf "target_library_CLI.mk ----------------------------------------\n" + @$(MAKE) -f $(RT_INCOMMON)/make/target_library_CLI.mk information + @printf "target_kmod.mk ----------------------------------------\n" + @$(MAKE) -f $(RT_INCOMMON)/make/target_kmod.mk information + +.PHONY: all +all: library CLI kmod + +.PHONY: library lib +library lib: + @$(MAKE) -f $(RT_INCOMMON)/make/target_library_CLI.mk library + +.PHONY: CLI +CLI: + @$(MAKE) -f $(RT_INCOMMON)/make/target_library_CLI.mk CLI + +.PHONY: kmod +kmod: + @$(MAKE) -f $(RT_INCOMMON)/make/target_kmod.mk kmod + +.PHONY: clean +clean: + @printf "local ----------------------------------------\n" + @printf "target_library_CLI.mk ----------------------------------------\n" + @$(MAKE) -f $(RT_INCOMMON)/make/target_library_CLI.mk clean + @printf "target_kmod.mk ----------------------------------------\n" + @$(MAKE) -f $(RT_INCOMMON)/make/target_kmod.mk clean 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/document/.gitkeep b/document/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/document/Harmony/00_Project_Structure.org b/document/Harmony/00_Project_Structure.org new file mode 100644 index 0000000..dc3acaf --- /dev/null +++ b/document/Harmony/00_Project_Structure.org @@ -0,0 +1,162 @@ +#+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 + +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 | +| =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 =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. + +** =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 + +** =tool/= + +We call the team members who administer the project and install tools the 'toolsmith'. The top level =tool/= directory holds the toolsmith's tools. + +** =shared/= + +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 =shared/authored= directory. Note the tool =scratchpad=, try =scratchpat help=. + +Tools installed from third parties go into the git ignored directory =shared/third_party=. + +** =developer/tool= + +Developer role specific tools. The =release= script and the RT project shared =make= scripts are found here. + +** =tester/tool= + +Tester role specific tools. + + +#+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 new file mode 100644 index 0000000..e34bcf6 --- /dev/null +++ b/document/Harmony/01_Workflow_and_Build_Contract.org @@ -0,0 +1,146 @@ +#+TITLE: 01 - Workflow and Build Contract +#+AUTHOR: RT +#+DATE: 2025-11-21 +#+OPTIONS: toc:2 num:nil + +#+HTML_HEAD_EXTRA: +#+HTML_HEAD_EXTRA: + +#+BEGIN_EXPORT html +
+#+END_EXPORT + +* Purpose +The workflow contract defines the steps from authorship through release of work product. + +There are three circular loops. + +In the development loop, developers author code and run experiments, eventually then promoting work product to the =release/= directory. + +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. + +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. + +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 =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 new file mode 100644 index 0000000..e5ced40 --- /dev/null +++ b/document/Harmony/02_RT_Code_Format.org @@ -0,0 +1,230 @@ +#+TITLE: 02 - RT Prescriptive Code Format Guide (Version 3) +#+AUTHOR: Thomas Walker Lynch +#+DATE: 2025-12-05 +#+OPTIONS: toc:2 num:nil + +#+HTML_HEAD_EXTRA: +#+HTML_HEAD_EXTRA: + +#+BEGIN_EXPORT html +
+#+END_EXPORT + +* Purpose + +The goal is consistency, readability, and predictability across all +languages and tools. + +This document covers: + +1. Naming conventions +2. Object vs. Instance Nomenclature +3. Vertical comma lists +4. Enclosure spacing +5. Line breaks and indentation +6. Cross-language guidance + +* Object vs. Instance Nomenclature + +In the RT world, we reserve the word 'object' for its general English meaning, as its technical meaning in programming often causes confusion. When discussing data that is manipulated solely through a defined interface, use the term **instance**. + +- **Object:** Anything that can be described or reasoned about. A 'math object' is anything defined using mathematics, and a 'Python object' is anything that can be described with Python syntax. +- **Instance:** Data that is only accessed or manipulated through a defined interface. This term is used to clearly denote data encapsulation and separation of concerns. + + +* Identifer Naming Conventions + +** Identifier Naming + +- Types, modules: *PascalCase* +- Functions, variables: *snake_case* +- Globals: UPPER_SNAKE_CASE + +** Proper Noun and Acronyms + +Even in PascalCase and snake_case, they remain capitalized, as per the English language convention. + +E.g.s + +- IEEE_publication_count +- person_Sara_novelties_list + + +** Suffix Semantics +Optionally suffixes are added to variable names to suggest type or interface. + +- =*_dp :: directory path, not specified if relative or absolute +- =*_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 + +If the file system node type is not specifically specified + +- =*_fs_nod_p :: file system node path, not specified if relative or absolute +- =*_fs_nod_pr :: relative file system node path +- =*_fs_nod_pa :: absolute file system node path + +- =*_list= :: generic ordered items +- =*_seq= :: ordered items accessed by index + +- =*_map= :: a keyed container +- =*_dict :: a keyed container + +- =*_count= :: number of elements +- =*_flag= :: boolean + +- = *_Type :: names specific type, where the type name is given in PascalCase, as is the norm for types. E.g.s =name_Array= or =name_Map= for the cases that name is an instance of a defined Array or Map type. + +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 belonging to the item that caused the comma to be needed. + +** Horizontal Comma List + +For lists on a single line, the comma is preceded by a space and abuts the item it follows. + +#+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. This applies to language statements and data values alike. + +** Vertical Comma List + +For lists spanning multiple lines, the comma is placed *before* the item on the new line, aligned with the item's indentation. + +#+BEGIN_SRC c +result = some_function( + first_argument + ,second_argument + ,third_argument +); +#+END_SRC + +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. +- This convention works identically across C, Python, Bash arrays, JSON-like data, etc. + +* Enclosure Spacing + +This rule applies on a line by line basis. + +** General Rules + +**No Space Between Adjacent Enclosures:** Generally, there is no space between adjacent enclosure punctuation (e.g., `f(g(x))`). + +** Single-Level Enclosures + +For enclosures that do not contain other enclosures (e.g., a simple `if(condition)`), there is **no space padding** inside the enclosure punctuation. + +Conforming: + +#+BEGIN_SRC c +if(condition){ + do_something(); +} +#+END_SRC + +Bad, non-conforming: + +#+BEGIN_SRC c +if(condition) { + do_something(); +} +#+END_SRC + +Bad, non-conforming: + +#+BEGIN_SRC c +if ( condition ) { + do_something ( ); +} +#+END_SRC + +** Multi-Level Enclosures + +For enclosures that contain other enclosures (e.g., `if( f(g(x)) )`), one space of padding is applied only to the **level one (outermost)** enclosure punctuation. All other levels follow the single-level rule (no padding). + +#+BEGIN_SRC c +if( f(g(x)) ){ + do_something(); +} +#+END_SRC + +In this example, the =if= has a three-level enclosure structure. The outermost parentheses of the =if= condition get one space of padding, while the inner parentheses for =f(...)= and =g(...)= get no padding. + +** Unmatched Enclosure Punctuation + +Format the enclosure punctuation that is present, as though it were matched. Treat an orphaned opening enclosure punctuation as though it were closed at the end of the line. Treat an extraneous closing, as though there were an opening at the beginning of the line. + +** Short Stuff Rule + +If a statement, such as an =if= block or a loop, can fit on a single line and is shorter than a reasonable line length (e.g., 40-60 characters), it should be kept on a single line without braces. + +#+BEGIN_SRC c +if(x == 0) return; +#+END_SRC + +* Indentation + +- Two spaces per indentation level. +- Never use tabs. +- Nest lines under the syntactic element that opened them. + +* Exercises + +To ensure a full understanding of the RT code format, please complete the following exercises. + +** Exercise 1: Comma and Function Call Formatting + +Reformat the following C code snippet to strictly adhere to the RT code format rules. Pay close attention to the horizontal and vertical comma lists, and the enclosure spacing for the function call. + +#+BEGIN_SRC c +void my_function(int a, int b, int c) { + int result = calculate_value(a, b, c); + printf("Result: %d, a: %d, b: %d, c: %d\n", result, a, b, c); +} + +result = my_function( + rediculously_long_first_argument, + rediculously_long_second_argument, + rediculously_long_third_argument +); +#+END_SRC + +** Exercise 2: Multi-Level Enclosure and Short Stuff Rule + +Reformat the following C code snippet. The `if` statement should use the multi-level enclosure rule, and the `for` loop body should use the short stuff rule. + +#+BEGIN_SRC c +if (check_permissions(user_id, file_path) && is_valid(file_path)) { + for (int i = 0; i < 10; i++) { + if (i % 2 == 0) { + printf("Even: %d\n", i); + } + } +} +#+END_SRC + +#+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/env_developer b/env_developer new file mode 100644 index 0000000..adb98f6 --- /dev/null +++ b/env_developer @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# env_developer — enter the project developer environment +# (must be sourced) + +script_afp=$(realpath "${BASH_SOURCE[0]}") +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + echo "$script_afp:: This script must be sourced, not executed." + exit 1 +fi + +# enter project environment +# + source shared/authored/env + +# setup tools +# + export PYTHON_HOME="$REPO_HOME/shared/third_party/Python" + if [[ ":$PATH:" != *":$PYTHON_HOME/bin:"* ]]; then + export PATH="$PYTHON_HOME/bin:$PATH" + fi + + RT_gcc="$REPO_HOME/shared/third_party/RT_gcc/release" + if [[ ":$PATH:" != *":$RT_gcc:"* ]]; then + export PATH="$RT_gcc:$PATH" + fi + +# enter the role environment +# + export ROLE=developer + + tool="$REPO_HOME/$ROLE/tool" + if [[ ":$PATH:" != *":$tool:"* ]]; then + export PATH="$tool:$PATH" + fi + + export ENV=$ROLE/tool/env + + cd "$ROLE" + if [[ -f "tool/env" ]]; then + source "tool/env" + echo "in environment: $ENV" + else + echo "not found: $ENV" + fi diff --git a/env_tester b/env_tester new file mode 100644 index 0000000..c0555c8 --- /dev/null +++ b/env_tester @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# env_tester — enter the project tester environment +# (must be sourced) + +script_afp=$(realpath "${BASH_SOURCE[0]}") +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + echo "$script_afp:: This script must be sourced, not executed." + exit 1 +fi + +# enter project environment +# + source shared/authored/env + +# setup tools +# + export PYTHON_HOME="$REPO_HOME/shared/third_party/Python" + if [[ ":$PATH:" != *":$PYTHON_HOME/bin:"* ]]; then + export PATH="$PYTHON_HOME/bin:$PATH" + fi + + RT_gcc="$REPO_HOME/shared/third_party/RT_gcc/release" + if [[ ":$PATH:" != *":$RT_gcc:"* ]]; then + export PATH="$RT_gcc:$PATH" + fi + +# enter the role environment +# + export ROLE=tester + + tool="$REPO_HOME/$ROLE/tool" + if [[ ":$PATH:" != *":$tool:"* ]]; then + export PATH="$tool:$PATH" + fi + + export ENV=$ROLE/tool/env + + cd "$ROLE" + if [[ -f "tool/env" ]]; then + source "tool/env" + echo "in environment: $ENV" + else + echo "not found: $ENV" + fi diff --git a/env_toolsmith b/env_toolsmith new file mode 100644 index 0000000..1d6b61f --- /dev/null +++ b/env_toolsmith @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# env_toolsmith — enter the project toolsmith environment +# (must be sourced) + +script_afp=$(realpath "${BASH_SOURCE[0]}") +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + echo "$script_afp:: This script must be sourced, not executed." + exit 1 +fi + +# enter project environment +# + source shared/authored/env + +# setup tools +# initially these will not exist, as the toolsmith installs them +# + export PYTHON_HOME="$REPO_HOME/shared/third_party/Python" + if [[ ":$PATH:" != *":$PYTHON_HOME/bin:"* ]]; then + export PATH="$PYTHON_HOME/bin:$PATH" + fi + + RT_gcc="$REPO_HOME/shared/third_party/RT_gcc/release" + if [[ ":$PATH:" != *":$RT_gcc:"* ]]; then + export PATH="$RT_gcc:$PATH" + fi + +# enter the role environment +# + export ROLE=toolsmith + + TOOL_DIR="$REPO_HOME/tool" + if [[ ":$PATH:" != *":$TOOL_DIR:"* ]]; then + export PATH="$TOOL_DIR:$PATH" + fi + + export ENV="tool/env" + + cd "$REPO_HOME" + if [[ -f "tool/env" ]]; then + source "tool/env" + echo "in environment: $ENV" + else + echo "not found: $ENV" + fi diff --git a/nohup.out b/nohup.out new file mode 100644 index 0000000..e69de29 diff --git a/release/authored/.gitkeep b/release/authored/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/release/documnt/.gitkeep b/release/documnt/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/release/made_tracked/.gitkeep b/release/made_tracked/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/release/made_untracked/.gitignore b/release/made_untracked/.gitignore new file mode 100644 index 0000000..bea49c8 --- /dev/null +++ b/release/made_untracked/.gitignore @@ -0,0 +1,3 @@ + +* +!/.gitignore \ No newline at end of file diff --git a/release/tool/.gitkeep b/release/tool/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scratchpad/.gitignore b/scratchpad/.gitignore new file mode 100644 index 0000000..120f485 --- /dev/null +++ b/scratchpad/.gitignore @@ -0,0 +1,2 @@ +* +!/.gitignore diff --git a/shared/authored/deprecated/git-empty-dir/CLI.py b/shared/authored/deprecated/git-empty-dir/CLI.py new file mode 100755 index 0000000..2fb22e1 --- /dev/null +++ b/shared/authored/deprecated/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/shared/authored/deprecated/git-empty-dir/Harmony.py b/shared/authored/deprecated/git-empty-dir/Harmony.py new file mode 120000 index 0000000..112663e --- /dev/null +++ b/shared/authored/deprecated/git-empty-dir/Harmony.py @@ -0,0 +1 @@ +../source_sync/Harmony.py \ No newline at end of file diff --git a/shared/authored/deprecated/git-empty-dir/load_command_module.py b/shared/authored/deprecated/git-empty-dir/load_command_module.py new file mode 120000 index 0000000..87b98be --- /dev/null +++ b/shared/authored/deprecated/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/shared/authored/deprecated/git-empty-dir/meta.py b/shared/authored/deprecated/git-empty-dir/meta.py new file mode 100644 index 0000000..dee6439 --- /dev/null +++ b/shared/authored/deprecated/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/shared/authored/deprecated/git-empty-dir/source_sync b/shared/authored/deprecated/git-empty-dir/source_sync new file mode 120000 index 0000000..9fd1d51 --- /dev/null +++ b/shared/authored/deprecated/git-empty-dir/source_sync @@ -0,0 +1 @@ +../source_sync/ \ No newline at end of file diff --git a/shared/authored/deprecated/gitignore_treewalk.py b/shared/authored/deprecated/gitignore_treewalk.py new file mode 100755 index 0000000..eef94ec --- /dev/null +++ b/shared/authored/deprecated/gitignore_treewalk.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +gitignore_walk.py — Fully correct .gitignore-aware depth-first walker +Now passes: + • __pycache__/ (directory listed, contents ignored) + • scratchpad/* !/.gitignore + • third_party/.gitignore ignoring everything inside + • top-level .gitignore +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Generator, List + + +@dataclass(frozen=True) +class Rule: + raw: str + negated: bool + dir_only: bool # pattern ends with / + anchored: bool # pattern starts with / + regex: re.Pattern + + +def _compile_rule(line: str) -> Rule | None: + line = line.strip() + if not line or line.startswith("#"): + return None + + negated = line.startswith("!") + if negated: + line = line[1:].lstrip() + + dir_only = line.endswith("/") + if dir_only: + line = line[:-1] + + anchored = line.startswith("/") + if anchored: + line = line[1:] + + # Convert git pattern to regex + parts = [] + i = 0 + while i < len(line): + c = line[i] + if c == "*": + if i + 1 < len(line) and line[i + 1] == "*": + parts.append(".*") + i += 2 + else: + parts.append("[^/]*") + i += 1 + elif c == "?": + parts.append("[^/]") + i += 1 + else: + parts.append(re.escape(c)) + i += 1 + + regex_str = "".join(parts) + + if anchored: + regex_str = f"^{regex_str}" + else: + regex_str = f"(^|/){regex_str}" + + # For dir-only patterns: match path + optional trailing slash + if dir_only: + regex_str += "(/.*)?$" + else: + regex_str += "($|/.*$)" + + return Rule( + raw=line, + negated=negated, + dir_only=dir_only, + anchored=anchored, + regex=re.compile(regex_str), + ) + + +def _load_rules(dirpath: Path) -> List[Rule]: + rules: List[Rule] = [] + gitignore = dirpath / ".gitignore" + if gitignore.is_file(): + try: + for raw_line in gitignore.read_text(encoding="utf-8", errors="ignore").splitlines(): + rule = _compile_rule(raw_line) + if rule: + rules.append(rule) + except Exception: + pass + return rules + + +def gitignore_walk(root: str | Path) -> Generator[Path, None, None]: + root = Path(root).resolve() + if not root.is_dir(): + return + + # Stack: (directory_path, rules_from_root_to_here) + stack: List[tuple[Path, List[Rule]]] = [(root, [])] + + while stack: + cur_dir, inherited_rules = stack.pop() # depth-first + + # Load local rules + local_rules = _load_rules(cur_dir) + all_rules = inherited_rules + local_rules + + # Relative path string from project root + try: + rel = cur_dir.relative_to(root) + rel_str = "" if rel == Path(".") else rel.as_posix() + except ValueError: + rel_str = "" + + # === Is this directory itself ignored? === + dir_ignored = False + for rule in reversed(all_rules): # last match wins + if rule.regex.match(rel_str + "/"): # always test as dir + dir_ignored = rule.negated + break + + # Yield the directory if not ignored + if not dir_ignored: + yield cur_dir + + # Scan children only if directory is not ignored + if dir_ignored: + continue + + try: + children = list(cur_dir.iterdir()) + except PermissionError: + continue + + children.sort(key=lambda p: p.name.lower()) + + to_visit = [] + for child in children: + if child.name == ".git": + continue + + child_rel = child.relative_to(root) + child_rel_str = child_rel.as_posix() + + # Special case: .gitignore files are never ignored by their own rules + if child.name == ".gitignore": + if not dir_ignored: + yield child + continue + + # Evaluate rules against the full relative path + ignored = False + for rule in reversed(all_rules): + match_str = child_rel_str + "/" if child.is_dir() else child_rel_str + if rule.regex.match(match_str): + ignored = rule.negated + break + + if not ignored: + if child.is_dir(): + to_visit.append(child) + else: + yield child + + # Push children in reverse order → depth-first, left-to-right + for child_dir in reversed(to_visit): + stack.append((child_dir, all_rules)) + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="Gitignore-aware tree walk") + parser.add_argument("path", nargs="?", default=".", help="Root directory") + args = parser.parse_args() + + for p in gitignore_walk(args.path): + print(p) diff --git a/shared/authored/deprecated/walk b/shared/authored/deprecated/walk new file mode 120000 index 0000000..cd6bd22 --- /dev/null +++ b/shared/authored/deprecated/walk @@ -0,0 +1 @@ +./gitignore_treewalk/CLI.py \ No newline at end of file diff --git a/shared/authored/deprecated/walk-dir-tree-w-gitignore/CLI.py b/shared/authored/deprecated/walk-dir-tree-w-gitignore/CLI.py new file mode 100755 index 0000000..1e3be48 --- /dev/null +++ b/shared/authored/deprecated/walk-dir-tree-w-gitignore/CLI.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + +from __future__ import annotations + +import os +import sys + +# ---------------------------------------------------------------------- +# Bootstrap import context when executed via symlink (e.g. ../walk) +# ---------------------------------------------------------------------- +if __name__ == "__main__" and __package__ is None: + # Resolve the real file (follows symlinks) + _real = os.path.realpath(__file__) + _pkg_dir = os.path.dirname(_real) + _pkg_root = os.path.dirname(_pkg_dir) # authored/ + + # Ensure authored/ is on sys.path + if _pkg_root not in sys.path: + sys.path.insert(0, _pkg_root) + + # Force package name so relative imports work + __package__ = "gitignore_treewalk" + +# Now safe to do relative imports +from .pattern import Pattern +from .ruleset import RuleSet +from .walker import Walker +from .printer import Printer + + +# ---------------------------------------------------------------------- +# Usage text +# ---------------------------------------------------------------------- +def usage() -> int: + print( + "Usage:\n" + " walk |usage|help\n" + " Show this help.\n" + "\n" + " walk list\n" + " Walk the working directory applying gitignore rules.\n" + ) + return 0 + + +# ---------------------------------------------------------------------- +# CLI dispatcher +# ---------------------------------------------------------------------- +def CLI(argv: List[str]) -> int: + if not argv: + return usage() + + cmd = argv[0] + + if cmd in ("usage", "help"): + return usage() + + if cmd == "list": + cwd = os.getcwd() + cwd_dpa = os.path.abspath(cwd) + + rs = RuleSet.from_gitignore_files( + start_dir=cwd_dpa + ) + + walker = Walker( + root=cwd_dpa + ,rules=rs + ) + + for p in walker.walk(): + print_path( + p + ,cwd_dpa + ) + return 0 + + print(f"Unknown command: {cmd}") + return usage() + + +# ---------------------------------------------------------------------- +# Entrypoint +# ---------------------------------------------------------------------- +if __name__ == "__main__": + sys.exit( + CLI( + sys.argv[1:] + ) + ) diff --git a/shared/authored/deprecated/walk-dir-tree-w-gitignore/__init__.py b/shared/authored/deprecated/walk-dir-tree-w-gitignore/__init__.py new file mode 100644 index 0000000..c3fae0e --- /dev/null +++ b/shared/authored/deprecated/walk-dir-tree-w-gitignore/__init__.py @@ -0,0 +1,15 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- +""" +gitignore_treewalk — Git-aware directory traversal library. + +Exports: + Pattern + RuleSet + Walker + Printer +""" + +from .pattern import Pattern +from .ruleset import RuleSet +from .walker import Walker +from .printer import Printer diff --git a/shared/authored/deprecated/walk-dir-tree-w-gitignore/pattern.py b/shared/authored/deprecated/walk-dir-tree-w-gitignore/pattern.py new file mode 100644 index 0000000..31718ae --- /dev/null +++ b/shared/authored/deprecated/walk-dir-tree-w-gitignore/pattern.py @@ -0,0 +1,115 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + +""" +pattern.py — Git ignore pattern parser. + +Implements: + Git pattern semantics: + - !negation + - directory-only ('foo/') + - anchored ('/foo') + - wildcards '*', '?' + - recursive wildcard '**' + - full-path matching + - last rule wins +""" + +from __future__ import annotations +import os +import re +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Pattern: + raw: str + negated: bool + anchored: bool + dir_only: bool + regex: re.Pattern + + @staticmethod + def from_line(line: str) -> Optional["Pattern"]: + """ + Parse a single .gitignore pattern line. + Return None for comments/empty. + """ + + stripped = line.strip() + if not stripped or stripped.startswith("#"): + return None + + negated = stripped.startswith("!") + if negated: + stripped = stripped[1:].lstrip() + if not stripped: + return None + + dir_only = stripped.endswith("/") + if dir_only: + stripped = stripped[:-1] + + anchored = stripped.startswith("/") + if anchored: + stripped = stripped[1:] + + # Convert git-style pattern to regex + # Git semantics: + # ** -> match any depth + # * -> match any sequence except '/' + # ? -> match one char except '/' + # + # Always match against full path (unix style, no leading '.') + # + def escape(s: str) -> str: + return re.escape(s) + + # Convert pattern piecewise + regex_pieces = [] + i = 0 + while i < len(stripped): + c = stripped[i] + if c == "*": + # Check for ** + if i + 1 < len(stripped) and stripped[i + 1] == "*": + # '**' -> match zero or more directories OR characters + regex_pieces.append(".*") + i += 2 + else: + # '*' -> match any chars except '/' + regex_pieces.append("[^/]*") + i += 1 + elif c == "?": + regex_pieces.append("[^/]") + i += 1 + else: + regex_pieces.append(escape(c)) + i += 1 + + regex_string = "".join(regex_pieces) + + # Anchored: match from start of path + # Unanchored: match anywhere in path + if anchored: + full = fr"^{regex_string}$" + else: + full = fr"(^|/){regex_string}($|/)" + + return Pattern( + raw=line, + negated=negated, + anchored=anchored, + dir_only=dir_only, + regex=re.compile(full), + ) + + def matches(self, relpath: str, is_dir: bool) -> bool: + """ + Match full relative path, not just basename. + """ + # If pattern is directory-only, relpath must be a directory + if self.dir_only and not is_dir: + return False + + return bool(self.regex.search(relpath)) diff --git a/shared/authored/deprecated/walk-dir-tree-w-gitignore/printer.py b/shared/authored/deprecated/walk-dir-tree-w-gitignore/printer.py new file mode 100644 index 0000000..288978c --- /dev/null +++ b/shared/authored/deprecated/walk-dir-tree-w-gitignore/printer.py @@ -0,0 +1,38 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + +""" +printer.py — utilities for printing path listings: + - linear list + - ASCII "tree" view where each line begins with the actual path, + then optional visual decoration for humans. +""" + +from __future__ import annotations +from pathlib import Path +from typing import Iterable + + +class Printer: + @staticmethod + def print_linear(paths: Iterable[Path], cwd: Path) -> None: + for p in paths: + rel = p.relative_to(cwd) + print(rel.as_posix()) + + @staticmethod + def print_tree(paths: Iterable[Path], cwd: Path) -> None: + """ + Print each line as: + + + Where is ASCII tree structure. + """ + items = sorted(paths, key=lambda p: p.relative_to(cwd).as_posix()) + rels = [p.relative_to(cwd).as_posix() for p in items] + + # Build a tree prefix for human reading + for rel in rels: + parts = rel.split("/") + indent = " " * (len(parts) - 1) + branch = "└─ " if len(parts) > 1 else "" + print(f"{rel} {indent}{branch}") diff --git a/shared/authored/deprecated/walk-dir-tree-w-gitignore/ruleset.py b/shared/authored/deprecated/walk-dir-tree-w-gitignore/ruleset.py new file mode 100644 index 0000000..d233de8 --- /dev/null +++ b/shared/authored/deprecated/walk-dir-tree-w-gitignore/ruleset.py @@ -0,0 +1,57 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + +""" +ruleset.py — layered Git ignore rule-set stack. + +Implements the Git semantics: + - Each directory can contribute patterns from .gitignore + - Parent directories apply first + - Last matching pattern wins + - Negation overrides earlier ignores + - dir-only rules respected +""" + +from __future__ import annotations +import os +from typing import List, Optional +from .pattern import Pattern + + +class RuleSet: + """ + Manages a stack of patterns from: + - global excludes + - .git/info/exclude + - directory-local .gitignore + + push(patterns) + pop(count) + evaluate(path, is_dir) + """ + + def __init__(self) -> None: + self.stack: List[List[Pattern]] = [] + + def push(self, patterns: List[Pattern]) -> None: + self.stack.append(patterns) + + def pop(self) -> None: + if self.stack: + self.stack.pop() + + def evaluate(self, relpath: str, is_dir: bool) -> bool: + """ + Return True iff path is ignored. + Last matching rule wins. + """ + verdict: Optional[bool] = None + + for group in self.stack: + for pat in group: + if pat.matches(relpath, is_dir): + if pat.negated: + verdict = False + else: + verdict = True + + return bool(verdict) diff --git a/shared/authored/deprecated/walk-dir-tree-w-gitignore/walker.py b/shared/authored/deprecated/walk-dir-tree-w-gitignore/walker.py new file mode 100644 index 0000000..57d89b9 --- /dev/null +++ b/shared/authored/deprecated/walk-dir-tree-w-gitignore/walker.py @@ -0,0 +1,121 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2 -*- + +""" +walker.py — Git-aware directory traversal. + +Features: + - Loads global excludes + - Loads .git/info/exclude if present + - Loads .gitignore in each directory + - Does NOT descend into ignored directories + - Yields both files and directories (Path objects) + - Always parent-before-child + - Sorted lexicographically +""" + +from __future__ import annotations +import os +from pathlib import Path +from typing import Iterator, List + +from .pattern import Pattern +from .ruleset import RuleSet + + +class Walker: + def __init__(self, root: Path) -> None: + self.root = root.resolve() + self.ruleset = RuleSet() + + # Load global and project-local excludes + self._push_global_excludes() + self._push_local_excludes() + + # ---------------------------------------------------------------------- + # Exclude Sources + # ---------------------------------------------------------------------- + + def _push_global_excludes(self) -> None: + """ + Load user's global ignore file if present: + ~/.config/git/ignore + or ~/.gitignore_global + """ + candidates = [ + Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "git" / "ignore", + Path.home() / ".gitignore_global" + ] + patterns = [] + + for f in candidates: + if f.exists(): + for line in f.read_text().splitlines(): + p = Pattern.from_line(line) + if p: + patterns.append(p) + break + + if patterns: + self.ruleset.push(patterns) + + def _push_local_excludes(self) -> None: + """ + Load /.git/info/exclude + """ + f = self.root / ".git" / "info" / "exclude" + patterns = [] + if f.exists(): + for line in f.read_text().splitlines(): + p = Pattern.from_line(line) + if p: + patterns.append(p) + + if patterns: + self.ruleset.push(patterns) + + # ---------------------------------------------------------------------- + # Walk + # ---------------------------------------------------------------------- + + def walk(self) -> Iterator[Path]: + return self._walk_dir(self.root, prefix="") + + def _walk_dir(self, dpath: Path, prefix: str) -> Iterator[Path]: + # Load .gitignore for this directory + patterns = [] + gitignore = dpath / ".gitignore" + if gitignore.exists(): + for line in gitignore.read_text().splitlines(): + p = Pattern.from_line(line) + if p: + patterns.append(p) + + self.ruleset.push(patterns) + + # Evaluate this directory (except root) + if prefix: + if self.ruleset.evaluate(prefix, is_dir=True): + # ignored directories are NOT descended into + self.ruleset.pop() + return + + yield dpath + + # Enumerate children sorted + entries: List[Path] = sorted(dpath.iterdir(), key=lambda p: p.name) + + for entry in entries: + rel = entry.relative_to(self.root).as_posix() + is_dir = entry.is_dir() + + # Skip ignored + if self.ruleset.evaluate(rel, is_dir=is_dir): + continue + + # Directories + if is_dir: + yield from self._walk_dir(entry, rel) + else: + yield entry + + self.ruleset.pop() diff --git a/shared/authored/env b/shared/authored/env new file mode 100644 index 0000000..35e91e4 --- /dev/null +++ b/shared/authored/env @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +script_afp=$(realpath "${BASH_SOURCE[0]}") +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + echo "$script_afp:: This script must be sourced, not executed." + exit 1 +fi + +# without this bash takes non-matching globs literally +shopt -s nullglob + +# does not presume sharing or world permissions +umask 0077 + +# -------------------------------------------------------------------------------- +# project definition + +# actual absolute director path for this script file + + script_adp(){ + dirname "$script_afp" + } + +# assume this script is located $REPO_HOME/tools_shared/authored and work backwards +# to get $REPO_HOME, etc. + + REPO_HOME=$(dirname "$(dirname "$(script_adp)")") + echo REPO_HOME "$REPO_HOME" + + PROJECT=$(basename "$REPO_HOME") + echo PROJECT "$PROJECT" + + # set the prompt decoration to the name of the project + PROMPT_DECOR=$PROJECT + + export REPO_HOME PROJECT PROMPT_DECOR + +# -------------------------------------------------------------------------------- +# Project wide Tool setup +# + +export VIRTUAL_ENV="$REPO_HOME/shared/third_party/Python" +export PYTHON_HOME="$VIRTUAL_ENV" +unset PYTHONHOME + + +# -------------------------------------------------------------------------------- +# PATH +# precedence: last defined, first discovered + + PATH="$REPO_HOME/shared/third_party/RT-project-share/release/bash:$PATH" + PATH="$REPO_HOME/shared/third_party/RT-project-share/release/amd64:$PATH" + PATH="$REPO_HOME/shared/third_party:$PATH" + PATH="$REPO_HOME/shared/customized:$PATH" + PATH="$REPO_HOME/shared/made:$PATH" + + # Remove duplicates + clean_path() { + PATH=$(echo ":$PATH" | awk -v RS=: -v ORS=: '!seen[$0]++' | sed 's/^://; s/:$//') + } + clean_path + export PATH + +# -------------------------------------------------------------------------------- +# the following functions are provided for other scripts to use. +# at the top of files that make use of these functions put the following line: +# script_afp=$(realpath "${BASH_SOURCE[0]}") +# + + ## script's filename + script_fn(){ + basename "$script_afp" + } + + ## script's dirpath relative to $REPO_HOME + script_fp(){ + realpath --relative-to="${REPO_HOME}" "$script_afp" + } + + ## script's dirpath relative to $REPO_HOME + script_dp(){ + dirname "$(script_fp)" + } + + export -f script_adp script_fn script_dp script_fp + +#-------------------------------------------------------------------------------- +# used by release scripts +# + + install_file() { + if [ "$#" -lt 3 ]; then + echo "env::install_file usage: install_file ... " + return 1 + fi + + perms="${@: -1}" # Last argument is permissions + target_dp="${@: -2:1}" # Second-to-last argument is the target directory + sources=("${@:1:$#-2}") # All other arguments are source files + + if [ ! -d "$target_dp" ]; then + echo "env::install_file no install done: target directory '$target_dp' does not exist." + return 1 + fi + + for source_fp in "${sources[@]}"; do + if [ ! -f "$source_fp" ]; then + echo "env::install_file: source file '$source_fp' does not exist." + return 1 + fi + + target_file="$target_dp/$(basename "$source_fp")" + + if ! install -m "$perms" "$source_fp" "$target_file"; then + echo "env::install_file: Failed to install $(basename "$source_fp") to $target_dp" + return 1 + else + echo "env::install_file: installed $(basename "$source_fp") to $target_dp with permissions $perms" + fi + done + } + + export -f install_file + +# -------------------------------------------------------------------------------- +# closing +# + if [[ -z "$ENV" ]]; then + export ENV=$(script_fp) + fi + diff --git a/shared/authored/sys b/shared/authored/sys new file mode 100644 index 0000000..e69de29 diff --git a/shared/authored/version b/shared/authored/version new file mode 100644 index 0000000..c86d0cf --- /dev/null +++ b/shared/authored/version @@ -0,0 +1,2 @@ +Harmony v0.1 2025-01-08 + diff --git a/shared/authored_10.zip b/shared/authored_10.zip new file mode 100644 index 0000000000000000000000000000000000000000..2326f1f025ff373909b0e26d481da796c3b96e32 GIT binary patch literal 77199 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 literal 0 HcmV?d00001 diff --git a/shared/document/.gitkeep b/shared/document/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/shared/document/install_Python.org b/shared/document/install_Python.org new file mode 100644 index 0000000..14342f9 --- /dev/null +++ b/shared/document/install_Python.org @@ -0,0 +1,75 @@ +#+TITLE: Installing Python in Harmony +#+AUTHOR: Thomas Walker Lynch +#+OPTIONS: toc:2 num:nil + +* Overview + +This document describes how to install a project-local Python environment under: + +#+begin_src bash +shared/third_party/Python +#+end_src + +This environment is shared across the =developer= and =tester= roles and is automatically activated through their respective =env_= scripts. + +* Precondition + +Ensure the following: + +- You are in a POSIX shell with =python3= installed. +- The =python3-venv= package is available (on Debian: =sudo apt install python3-venv=). +- You have sourced the Harmony environment via =env_toolsmith= to initialize =REPO_HOME= and related variables. + +* Step-by-Step Installation + +1. Source the Harmony environment: + #+begin_src bash + source env_toolsmith + #+end_src + +2. Create the virtual environment: + #+begin_src bash + python3 -m venv "$REPO_HOME/shared/third_party/Python" + #+end_src + +3. Activate it temporarily to install required packages: + #+begin_src bash + source "$REPO_HOME/shared/third_party/Python/bin/activate" + pip install --upgrade pip + pip install pytest # Add any shared packages here + deactivate + #+end_src + +4. Rename Python's default activate and deactivate: + Harmony provides its own role-aware environment management. Using Python’s default activation scripts may interfere with prompt logic, PATH order, and role-specific behavior. + + Disable the default scripts by renaming them: + #+begin_src bash + mv "$REPO_HOME/shared/third_party/Python/bin/activate" \ + "$REPO_HOME/shared/third_party/Python/bin/activate_deprecated" + #+end_src + + This ensures that accidental sourcing of Python’s =activate= script won't override Harmony's environment setup. + +5. Verify installation: + #+begin_src bash + ls "$REPO_HOME/shared/third_party/Python/bin/python3" + #+end_src + + The binary should exist and report a working Python interpreter when run. + +* Notes + +- The virtual environment is deliberately named =Python=, not =venv=, to reflect its role as a shared system component. +- Harmony environment scripts define and control =VIRTUAL_ENV=, =PYTHON_HOME=, and =PATH=, making Python activation seamless and uniform. +- There is no need to use Python’s =bin/activate= directly — it is fully replaced by Harmony’s environment logic. + +* Related Files + +- =shared/authored/env= +- =shared/authored/env_source= +- =env_developer=, =env_tester=, =env_toolsmith= + +* Last Verified + +2025-05-19 :: Activate/deactivate renamed post-install. Requires Harmony environment sourcing prior to execution. diff --git a/shared/document/install_generic.org b/shared/document/install_generic.org new file mode 100644 index 0000000..90af13e --- /dev/null +++ b/shared/document/install_generic.org @@ -0,0 +1,81 @@ + +This is the generic install.org doc that comes with the skeleton. + +1. $REPO_HOME/shared/third_party/.gitignore: + + * + !/.gitignore + !/patch + + The only things from the third party directory that will be pushed to the repo origin is the .gitignore file and the patches. + + +2. downloaded tar files etc. go into the directory `upstream` + + $REPO_HOME/shared/upstream + + Typically the contents of upstream are deleted after the install. + +3. for the base install + + cd $REPO_HOME/shared/third_party + do whatever it takes to install tool, as examples: + git clone + tar -xzf ../upstream/tar + ... + + Be sure to add the path to the tool executable(s) in the $REPO_HOME/env_$ROLE files for the $ROLE who uses the tool. + + Assuming you are not also developing the tool, for safety + change each installed git project to a local branch: + + b=__local_$USER + git switch -c "$b" + + +4. Define some variables to simplify our discussion. Lowercase variable names + are not exported from the shell. + + # already set in the environment + # REPO_HOME + # PROJECT + # USER + + # example tool names: 'RT_gcc' 'RT-project share` etc. + tool= + tool_dpath="$REPO_HOME/shared/third_party/$tool" + patch_dpath="$REPO_HOME/shared/patch/" + + +5. create a patch series (from current vendor state → your local edits) + + # this can be repeated and will create an encompassing diff file + + # optionally crate a new branch after cloning the third party tool repo and work from there. You won't make any commits, but in case you plan to ever check the changes in, or have a the bad habit of doing ommits burned into your brain-stem, making a brnch will help. + + # make changes + + cd "$tool_dpath" + + # do your edits + + # Stage edits. Do not commit them!! Be sure you are in the third party + # tool directory when doing `git add -A` and `git diff` commands. + git add -A + + # diff the stage from the current repo to create the patch file + git diff --staged > "$patch_dpath/$tool" + + # the diff file can be added to the project and checked in at the project level. + + +6. how to apply an existing patch + + Get a fresh clone of the tool into $tool_dpath. + + cd "$tool_dpath" + git apply "$patch_dpath/$tool" + + You can see what `git apply` would do by running + + git apply --check /path/to/your/patch_dpath/$tool diff --git a/shared/made/walk b/shared/made/walk new file mode 120000 index 0000000..1325c19 --- /dev/null +++ b/shared/made/walk @@ -0,0 +1 @@ +../authored/gitignore_treewalk.py \ No newline at end of file diff --git a/shared/third_party/.gitignore b/shared/third_party/.gitignore new file mode 100644 index 0000000..0de97f0 --- /dev/null +++ b/shared/third_party/.gitignore @@ -0,0 +1,8 @@ +# Ignore all files +* + +# But don't ignore the .gitignore file itself +!/.gitignore + +# keep the upstream directory +!/upstream diff --git a/shared/third_party/upstream/.gitignore b/shared/third_party/upstream/.gitignore new file mode 100644 index 0000000..aa0e8eb --- /dev/null +++ b/shared/third_party/upstream/.gitignore @@ -0,0 +1,2 @@ +* +!/.gitignore \ No newline at end of file diff --git a/temp.sh b/temp.sh new file mode 100644 index 0000000..c166d29 --- /dev/null +++ b/temp.sh @@ -0,0 +1,11 @@ +2025-11-25 09:33:05 Z [subu:developer] Thomas_developer@StanleyPark +§/home/Thomas/subu_data/developer/subu_data/Harmony§ +> find . -type l -exec ls -l {} \; +lrwxrwxrwx 1 Thomas_developer Thomas_developer 35 Nov 25 09:08 ./tool/sync -> ../tool_shared/authored/sync/CLI.py +lrwxrwxrwx 1 Thomas_developer Thomas_developer 3 May 19 2025 ./shared/third_party/Python/lib64 -> lib +lrwxrwxrwx 1 Thomas_developer Thomas_developer 16 May 19 2025 ./shared/third_party/Python/bin/python3 -> /usr/bin/python3 +lrwxrwxrwx 1 Thomas_developer Thomas_developer 7 May 19 2025 ./shared/third_party/Python/bin/python -> python3 +lrwxrwxrwx 1 Thomas_developer Thomas_developer 7 May 19 2025 ./shared/third_party/Python/bin/python3.11 -> python3 +lrwxrwxrwx 1 Thomas_developer Thomas_developer 15 Nov 24 15:19 ./shared/authored/git-empty-dir/source_sync -> ../source_sync/ +lrwxrwxrwx 1 Thomas_developer Thomas_developer 25 Nov 24 15:21 ./shared/authored/git-empty-dir/Harmony.py -> ../source_sync/Harmony.py +lrwxrwxrwx 1 Thomas_developer Thomas_developer 37 Nov 24 15:22 ./shared/authored/git-empty-dir/load_command_module.py -> ../source_sync/load_command_module.py diff --git a/tester/.gitkeep b/tester/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tester/RT_Format/RT_Format b/tester/RT_Format/RT_Format new file mode 100755 index 0000000..2b51ceb --- /dev/null +++ b/tester/RT_Format/RT_Format @@ -0,0 +1,415 @@ +#!/usr/bin/env -S python3 -B +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +RT_Format — Reasoning Technology code formatter (commas + bracketed phrases per line) + +Commands: + RT_Format write Format files in place (rewrite originals) + RT_Format copy Save backups as ~ then format originals + RT_Format pipe Read from stdin, write to stdout + RT_Format self_test Run built-in tests + RT_Format version Show tool version + RT_Format help | --help Show usage + +Rules: + • Horizontal lists -> a ,b ,c (space BEFORE comma, none after) + • Tight (){}[] by default; add one space just inside borders only when an + OUTERMOST bracketed phrase on the line contains an INNER bracket. + • Multiple outermost phrases can exist on a line (e.g., `g() { ... }`); + apply the rule to EACH such phrase independently. + • Per-line, tolerant of unbalanced brackets: first unmatched opener OR last + unmatched closer is treated as “the” outermost for padding purposes. + • Strings and single-line comments (#, //) are not altered. +""" + +from typing import List ,Tuple ,Optional ,TextIO +import sys ,re ,io ,shutil ,os + +RTF_VERSION = "0.2.2" # pad all outermost-with-nesting phrases on a line + +BR_OPEN = "([{<" +BR_CLOSE = ")]}>" +PAIR = dict(zip(BR_OPEN ,BR_CLOSE)) +REV = dict(zip(BR_CLOSE ,BR_OPEN)) + +USAGE = """\ +Usage: + RT_Format write + RT_Format copy + RT_Format pipe + RT_Format self_test + RT_Format version + RT_Format help | --help +""" + +# --------------- Core token helpers ---------------- + +def split_code_comment(line: str): + """Return (code ,comment), keeping the comment marker if present; ignore markers inside strings.""" + in_s = None + esc = False + for i ,ch in enumerate(line): + if in_s: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == in_s: + in_s = None + continue + else: + if ch in ("'" ,'"'): + in_s = ch + continue + if ch == "#": + return line[:i] ,line[i:] + if ch == "/" and i + 1 < len(line) and line[i + 1] == "/": + return line[:i] ,line[i:] + return line ,"" + +def format_commas(code: str) -> str: + """Space BEFORE comma, none after, outside strings.""" + out: List[str] = [] + in_s = None + esc = False + i = 0 + while i < len(code): + ch = code[i] + if in_s: + out.append(ch) + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == in_s: + in_s = None + i += 1 + else: + if ch in ("'" ,'"'): + in_s = ch + out.append(ch) + i += 1 + elif ch == ",": + while out and out[-1] == " ": + out.pop() + if out and out[-1] != " ": + out.append(" ") + out.append(",") + j = i + 1 + while j < len(code) and code[j] == " ": + j += 1 + i = j + else: + out.append(ch) + i += 1 + return "".join(out) + +# --------------- Bracket discovery ---------------- + +def top_level_spans(code: str) -> List[Tuple[int ,int]]: + """Return all balanced OUTERMOST bracketed spans (start,end) for this line, ignoring strings.""" + in_s = None + esc = False + stack: List[Tuple[str ,int]] = [] + spans: List[Tuple[int ,int]] = [] + for i ,ch in enumerate(code): + if in_s: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == in_s: + in_s = None + continue + else: + if ch in ("'" ,'"'): + in_s = ch + continue + if ch in BR_OPEN: + stack.append((ch ,i)) + elif ch in BR_CLOSE: + if stack and REV[ch] == stack[-1][0]: + _ ,pos = stack.pop() + if not stack: + spans.append((pos ,i)) + else: + # unmatched closer ignored here; handled in unbalanced logic + pass + return spans + +def first_unmatched_opener(code: str) -> Optional[int]: + in_s = None + esc = False + stack: List[Tuple[str ,int]] = [] + for i ,ch in enumerate(code): + if in_s: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == in_s: + in_s = None + continue + else: + if ch in ("'" ,'"'): + in_s = ch + continue + if ch in BR_OPEN: + stack.append((ch ,i)) + elif ch in BR_CLOSE: + if stack and REV[ch] == stack[-1][0]: + stack.pop() + else: + # unmatched closer: do nothing here + pass + return stack[0][1] if stack else None + +def last_unmatched_closer(code: str) -> Optional[int]: + in_s = None + esc = False + depth = 0 + last: Optional[int] = None + for i ,ch in enumerate(code): + if in_s: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == in_s: + in_s = None + continue + else: + if ch in ("'" ,'"'): + in_s = ch + continue + if ch in BR_OPEN: + depth += 1 + elif ch in BR_CLOSE: + if depth > 0: + depth -= 1 + else: + last = i + return last + +def contains_inner_bracket(code: str ,start: Optional[int] ,end: Optional[int]) -> bool: + """Check for any bracket token inside the given bounds (respect strings).""" + if start is None and end is None: + return False + in_s = None + esc = False + lo = (start + 1) if start is not None else 0 + hi = (end - 1) if end is not None else len(code) - 1 + if hi < lo: + return False + for i ,ch in enumerate(code): + if i < lo or i > hi: + continue + if in_s: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == in_s: + in_s = None + continue + else: + if ch in ("'" ,'"'): + in_s = ch + continue + if ch in BR_OPEN or ch in BR_CLOSE: + return True + return False + +# --------------- Spacing transforms ---------------- + +def tighten_all_brackets(code: str) -> str: + """Tight margins and remove immediate interior spaces next to borders.""" + out: List[str] = [] + in_s = None + esc = False + i = 0 + while i < len(code): + ch = code[i] + if in_s: + out.append(ch) + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == in_s: + in_s = None + i += 1 + else: + if ch in ("'" ,'"'): + in_s = ch + out.append(ch) + i += 1 + elif ch in BR_CLOSE: + if out and out[-1] == " ": + out.pop() + out.append(ch) + i += 1 + elif ch in BR_OPEN: + if out and out[-1] == " ": + out.pop() + out.append(ch) + i += 1 + while i < len(code) and code[i] == " ": + i += 1 + else: + out.append(ch) + i += 1 + return "".join(out) + +def apply_bracket_padding(code: str) -> str: + """ + 1) Tighten globally. + 2) For EACH balanced outermost span, if it contains an inner bracket, + ensure exactly one space just inside its borders — but only if missing. + 3) If there are no balanced spans, pad the first unmatched opener OR the last unmatched closer + only if that outer fragment contains an inner bracket, and only if padding is missing. + """ + s = tighten_all_brackets(code) + + def borders_have_space(text: str, start: int, end: int) -> Tuple[bool, bool]: + # Return (left_has_space, right_has_space) for just-inside borders. + left_has = (start + 1 < len(text)) and (text[start + 1] == " ") + right_has = (end - 1 >= 0) and (text[end - 1] == " ") + return left_has, right_has + + # Balanced top-level spans: may be multiple on one line (e.g., g() { ... }). + # Iterate while applying at most one mutation per pass; recompute spans after. + while True: + spans = top_level_spans(s) + changed = False + for (start, end) in spans: + if contains_inner_bracket(s, start, end): + left_has, right_has = borders_have_space(s, start, end) + if not left_has or not right_has: + # Insert exactly one space just inside each border that lacks it. + if not right_has: + # Right side first to avoid shifting the 'start' index computation + s = s[:end].rstrip(" ") + " " + s[end:].lstrip(" ") + if not left_has: + s = s[:start + 1].rstrip(" ") + " " + s[start + 1:].lstrip(" ") + changed = True + break # after a mutation, recompute spans fresh + if not changed: + break + + # If there are no balanced spans, consider unbalanced fragment once + if not top_level_spans(s): + o = first_unmatched_opener(s) + c = last_unmatched_closer(s) + if o is not None and contains_inner_bracket(s, o, None): + # add one space after opener only if missing + if not (o + 1 < len(s) and s[o + 1] == " "): + s = s[:o + 1].rstrip(" ") + " " + s[o + 1:] + elif c is not None and contains_inner_bracket(s, None, c): + # add one space before closer only if missing + if not (c - 1 >= 0 and s[c - 1] == " "): + s = s[:c].rstrip(" ") + " " + s[c:] + + return s + +# --------------- Public API ---------------- + +def rt_format_line(line: str) -> str: + code ,comment = split_code_comment(line.rstrip("\n")) + code = format_commas(code) + code = apply_bracket_padding(code) + return code + comment + +def rt_format_text(text: str) -> str: + return "\n".join(rt_format_line(ln) for ln in text.splitlines()) + +def rt_format_stream(inp: TextIO ,out: TextIO) -> None: + for line in inp: + out.write(rt_format_line(line) + "\n") + +# --------------- Self-test ---------------- + +def run_self_test() -> bool: + ok = True + def chk(src ,exp): + nonlocal ok + got = rt_format_line(src) + if got != exp: + print("FAIL:" ,src ,"=>" ,got ,"expected:" ,exp) + ok = False + + # Commas + chk("a,b,c" ,"a ,b ,c") + chk("a , b , c" ,"a ,b ,c") + + # Tight () by default + chk("f ( x )" ,"f(x)") + chk("f(x) + g(y)" ,"f(x) + g(y)") + + # Balanced: multiple outermost spans (g() and {...}) -> only pad {...} if it has inner bracket + src = "int g(){int a=0,b=1,c=2; return h(a,b,c);}" + exp = "int g(){ int a=0 ,b=1 ,c=2; return h(a ,b ,c); }" + chk(src ,exp) + + # Balanced: single outermost with nesting + chk("outer( inner(a,b) )" ,"outer( inner(a ,b) )") + + # Unbalanced open-right with nesting + chk("compute(x, f(y" ,"compute( x ,f(y)") + + # Unbalanced open-left without prior inner bracket => unchanged + chk("return z) + 1" ,"return z) + 1") + + print("SELFTEST OK" if ok else "SELFTEST FAILED") + return ok + +# --------------- CLI ---------------- + +def write_files(paths: List[str]) -> int: + for path in paths: + with open(path ,"r" ,encoding="utf-8") as f: + data = f.read() + formatted = rt_format_text(data) + with open(path ,"w" ,encoding="utf-8") as f: + f.write(formatted + ("\n" if not formatted.endswith("\n") else "")) + return 0 + +def copy_files(paths: List[str]) -> int: + for path in paths: + shutil.copy2(path ,path + "~") + return write_files(paths) + +def CLI(argv=None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not args or args[0] in {"help" ,"--help" ,"-h"}: + print(USAGE) + return 0 + + cmd = args[0] + rest = args[1:] + + if cmd == "version": + print(RTF_VERSION) + return 0 + if cmd == "self_test": + ok = run_self_test() + return 0 if ok else 1 + if cmd == "pipe": + rt_format_stream(sys.stdin ,sys.stdout) + return 0 + if cmd == "write": + if not rest: + print("write: missing \n" + USAGE) + return 2 + return write_files(rest) + if cmd == "copy": + if not rest: + print("copy: missing \n" + USAGE) + return 2 + return copy_files(rest) + + print(f"Unknown command: {cmd}\n" + USAGE) + return 2 + +if __name__ == "__main__": + sys.exit(CLI()) diff --git a/tester/RT_Format/RT_Format.el b/tester/RT_Format/RT_Format.el new file mode 100644 index 0000000..a9f6a2d --- /dev/null +++ b/tester/RT_Format/RT_Format.el @@ -0,0 +1,4 @@ +(defun rt-format-buffer () + (interactive) + (shell-command-on-region (point-min) (point-max) + "RT_Format pipe" t t)) diff --git a/tester/RT_Format/test_0_data.c b/tester/RT_Format/test_0_data.c new file mode 100644 index 0000000..7b1e06d --- /dev/null +++ b/tester/RT_Format/test_0_data.c @@ -0,0 +1,15 @@ +// commas and simple tight brackets +int g(){int a=0,b=1,c=2; return h(a,b,c);} + +// balanced outermost-with-nesting -> pad inside outer () +int f(){return outer( inner(a,b) );} + +// strings and comments must be unchanged +int s(){ printf("x ,y ,z (still a string)"); /* a ,b ,c */ return 1; } + +// unbalanced open-right with nesting -> pad after first unmatched '(' +int u(){ if(doit(foo(1,2) // missing )) + return 0; } + +// arrays / subscripts stay tight; commas still RT-style +int a(int i,int j){ return M[i,j] + V[i] + W[j]; } diff --git a/tester/RT_Format/test_1_data.py b/tester/RT_Format/test_1_data.py new file mode 100644 index 0000000..9b2fa87 --- /dev/null +++ b/tester/RT_Format/test_1_data.py @@ -0,0 +1,16 @@ +# commas and spacing in defs / calls +def f ( x , y , z ): + return dict( a =1 , b= 2 ), [ 1, 2 ,3 ], ( (1,2) ) + +# outermost-with-nesting -> pad inside outer () +val = outer( inner( a,b ) ) + +# strings/comments untouched +s = "text, with , commas ( not to touch )" # a ,b ,c + +# unbalanced: open-left (closing without opener) -> no padding unless inner bracket before it +def g(): + return result) # likely unchanged + +# unbalanced: open-right (first unmatched opener) with inner bracket following +k = compute(x, f(y diff --git a/tester/tool/env b/tester/tool/env new file mode 100644 index 0000000..0b993ad --- /dev/null +++ b/tester/tool/env @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +script_afp=$(realpath "${BASH_SOURCE[0]}") + diff --git a/tool/Harmony_sync b/tool/Harmony_sync new file mode 120000 index 0000000..22ddb4e --- /dev/null +++ b/tool/Harmony_sync @@ -0,0 +1 @@ +../shared/authored/Harmony_sync/CLI.py \ No newline at end of file diff --git a/tool/after_pull b/tool/after_pull new file mode 100755 index 0000000..946d9ad --- /dev/null +++ b/tool/after_pull @@ -0,0 +1,124 @@ +#!/usr/bin/env -S python3 -B +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +""" +set_project_permissions — normalize a freshly cloned project to Harmony policies. + +usage: + set_project_permissions [default] + set_project_permissions help | --help | -h + +notes: + • Must be run from the toolsmith environment (ENV=tool/env, ROLE=toolsmith). + • Starts at $REPO_HOME. + • Baseline is umask-077 congruence: + - directories → 0700 + - files → 0600, but preserve owner-exec (→ 0700 for executables) + applied to the entire repo, including release/, EXCEPT: + - release/kmod/*.ko → 0440 + • Skips .git/ and symlinks. +""" + +import os, sys, stat + +# Must match shared/authored/env policy: +DEFAULT_UMASK = 0o077 # reminder only; effective modes below implement 077 congruence. + +DIR_MODE_077 = 0o700 + +def die(msg, code=1): + print(f"set_project_permissions: {msg}", file=sys.stderr) + sys.exit(code) + +def require_toolsmith_env(): + env = os.environ.get("ENV", "") + role = os.environ.get("ROLE", "") + if env != "tool/env" or role != "toolsmith": + hint = ( + "This script should be run from the toolsmith environment.\n" + "Try: source ./env_toolsmith (then re-run: set_project_permissions default)" + ) + die(f"bad environment: ENV='{env}' ROLE='{role}'.\n{hint}") + +def repo_home(): + rh = os.environ.get("REPO_HOME") + if not rh: + die("REPO_HOME is not set (did you source shared/authored/env?)") + return os.path.realpath(rh) + +def show_path(p, rh): + return p.replace(rh, "$REPO_HOME", 1) if p.startswith(rh) else p + +def is_git_dir(path): + return os.path.basename(path.rstrip(os.sep)) == ".git" + +def file_target_mode_077_preserve_exec(current_mode: int) -> int: + # Base 0600, add owner exec if currently set; drop all group/other. + target = 0o600 + if current_mode & stat.S_IXUSR: + target |= stat.S_IXUSR + return target + +def set_mode_if_needed(path, target, rh): + try: + st = os.lstat(path) + except FileNotFoundError: + return 0 + cur = stat.S_IMODE(st.st_mode) + if cur == target: + return 0 + os.chmod(path, target) + print(f"+ chmod {oct(target)[2:]} '{show_path(path, rh)}'") + return 1 + +def apply_policy(rh): + changed = 0 + release_root = os.path.join(rh, "release") + for dirpath, dirnames, filenames in os.walk(rh, topdown=True, followlinks=False): + # prune .git + dirnames[:] = [d for d in dirnames if d != ".git"] + + # directories: 0700 everywhere (incl. release/) + changed += set_mode_if_needed(dirpath, DIR_MODE_077, rh) + + # files: 0600 (+owner exec) everywhere, except release/kmod/*.ko → 0440 + rel_from_repo = os.path.relpath(dirpath, rh) + under_release = rel_from_repo == "release" or rel_from_repo.startswith("release"+os.sep) + top_under_release = "" + if under_release: + rel_from_release = os.path.relpath(dirpath, release_root) + top_under_release = (rel_from_release.split(os.sep, 1)[0] if rel_from_release != "." else "") + + for fn in filenames: + p = os.path.join(dirpath, fn) + if os.path.islink(p): + continue + try: + st = os.lstat(p) + except FileNotFoundError: + continue + + if under_release and top_under_release == "kmod" and fn.endswith(".ko"): + target = 0o440 + else: + target = file_target_mode_077_preserve_exec(stat.S_IMODE(st.st_mode)) + + changed += set_mode_if_needed(p, target, rh) + return changed + +def cmd_default(): + require_toolsmith_env() + rh = repo_home() + total = apply_policy(rh) + print(f"changes: {total}") + +def main(): + if len(sys.argv) == 1 or sys.argv[1] in ("default",): + return cmd_default() + if sys.argv[1] in ("help", "--help", "-h"): + print(__doc__.strip()); return 0 + # unknown command → help + print(__doc__.strip()); return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tool/env b/tool/env new file mode 100644 index 0000000..0b993ad --- /dev/null +++ b/tool/env @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +script_afp=$(realpath "${BASH_SOURCE[0]}") + diff --git a/tool/git-tar b/tool/git-tar new file mode 100755 index 0000000..d6ba1f7 --- /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 / "shared" / "third_party" / "RT-project-share" / "release" / "python" / "Z", + repo_top / "shared" / "third_party" / "RT-project-share" / "release" / "python" / "Z.py", + repo_top / "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/release b/tool/release new file mode 100755 index 0000000..e99629c --- /dev/null +++ b/tool/release @@ -0,0 +1,291 @@ +#!/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 = 0o700 # 077-congruent dirs + +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: + 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: + 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 (two-pass owner:group width) ---------- +def list_tree(root): + if not os.path.isdir(root): + return + entries = [] + 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: + 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: + 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) + + ogw = 0 + for (_isdir, _depth, _perms, ownergrp, _name) in entries: + if len(ownergrp) > ogw: ogw = len(ownergrp) + + print("release/") + for (_isdir, depth, perms, ownergrp, name) in entries: + indent = " " * depth + 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_from_source(src_abs: str) -> int: + """077 policy: files 0600; if source has owner-exec, make 0700.""" + try: + sm = stat.S_IMODE(os.stat(src_abs).st_mode) + except FileNotFoundError: + return 0o600 + return 0o700 if (sm & stat.S_IXUSR) else 0o600 + +def copy_one(src_abs, dst_abs, 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) + target_mode = _target_mode_from_source(src_abs) + + def _is_writable_dir(p): return os.access(p, os.W_OK) + flip_needed = not _is_writable_dir(parent) + restore_mode = None + parent_show = _display_dst(parent) + + if dry: + if flip_needed: + print(f"(dry) chmod u+w '{parent_show}'") + if os.path.exists(dst_abs): + print(f"(dry) unlink '{dst_show}'") + # show final mode we will set + print(f"(dry) install -m {oct(target_mode)[2:]} -D '{src_show}' '{dst_show}'") + if flip_needed: + print(f"(dry) chmod u-w '{parent_show}'") + return + + try: + if flip_needed: + try: + st_parent = os.stat(parent) + restore_mode = stat.S_IMODE(st_parent.st_mode) + os.chmod(parent, restore_mode | stat.S_IWUSR) + except PermissionError: + exit_with_status(f"cannot write: parent dir not writable and chmod failed on {parent_show}") + + # Atomic replace with enforced 077-compliant mode + 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, target_mode) + os.replace(tmp_path, dst_abs) + finally: + try: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception: + pass + finally: + if restore_mode is not None: + try: os.chmod(parent, restore_mode) + except Exception: pass + + print(f"+ install -m {oct(target_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 + 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, 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() -- 2.20.1